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

@@ -59,12 +59,21 @@ changes on top of that:
* **A new part shows today's agenda.** `CalendarPartProvider`
(`app/ui/.../widgets/clock/parts/`) is toggled with the *Events* switch
(`clockWidgetCalendarPart`). It lists the events that are still running or upcoming today, plus
today's all-day events, capped at 4 rows — if there are more, the last row is a "+N more events"
hint that opens the calendar app. Tapping a row opens the event
today's all-day events, capped at `clockWidgetCalendarPartMaxRows` rows (1..8, default 4) — if
there are more, the rows are followed by a "+N more events" hint that opens the calendar app.
Tapping a row opens the event
(`CalendarEvent.launch`, i.e. `ACTION_VIEW` on `content://com.android.calendar/events/<id>`,
which KashCal handles). Rows reuse `CalendarEvent.getSummary()` from
`ui/launcher/search/calendar/CalendarItem.kt` (made `internal` for this), so an all-day event
reads "All day" and a timed one reads e.g. "09:00 09:30".
* **The agenda picks its calendars.** `clockWidgetCalendarPartExcludedCalendars` (empty = all)
hides individual calendars; the query's exclusion set is that set plus the search settings'
exclusions, because the picker only ever offers calendars the search settings leave enabled
(`ClockWidgetSettingsScreenVM.calendars` filters them out of `CalendarRepository.getCalendars()`,
and a "Manage calendars" button opens `Settings → Search → Calendar` via the
`ROUTE_SEARCH_CALENDARS` route in `SettingsActivity`). The whole calendar configuration lives in
`ConfigureCalendarPart.kt`, shown by `ConfigureClockWidgetSheet` while the Events part is on —
the agenda deliberately ignores the calendar *widget*'s own `excludedCalendarIds`.
Rankings, in the order the zone prefers them: `CalendarPartProvider` 70 when an event is running or
starts within 30 minutes, else 30 while there are events left today, else 0 (so the date part keeps
@@ -78,8 +87,10 @@ launcher only re-query when they become visible). `PartProvider.setTime()` is ca
by the clock widget; the provider folds that into its ranking instead of re-querying.
The clock settings themselves are in `core/preferences`: `LauncherSettingsData` (new fields
`clockWidgetCalendarPart`, `clockWidgetDynamicZoneSlots`), `ClockWidgetParts` and the
`ClockWidgetSettings` accessors in `core/preferences/.../ui/ClockWidgetSettings.kt`, and the UI
`clockWidgetCalendarPart`, `clockWidgetCalendarPartExcludedCalendars`,
`clockWidgetCalendarPartMaxRows`, `clockWidgetDynamicZoneSlots`), `ClockWidgetParts` and the
`ClockWidgetSettings` accessors in `core/preferences/.../ui/ClockWidgetSettings.kt` (including the
`MinCalendarPartRows`/`MaxCalendarPartRows` bounds), and the UI
state in `ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt`. New strings are English-only in
`core/i18n/src/main/res/values/strings.xml`; translations come from Crowdin upstream.

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)

View File

@@ -615,6 +615,12 @@
<string name="preference_clockwidget_alarm_part_summary">Show alarms that will ring within the next 24 hours</string>
<string name="preference_clockwidget_calendar_part">Events</string>
<string name="preference_clockwidget_calendar_part_summary">Show today\'s agenda</string>
<string name="preference_clockwidget_calendar_part_events">Visible events</string>
<string name="preference_clockwidget_calendar_part_calendars">Calendars</string>
<string name="preference_clockwidget_calendar_part_all_calendars">All calendars</string>
<string name="preference_clockwidget_calendar_part_calendars_summary">%1$d of %2$d calendars</string>
<string name="preference_clockwidget_calendar_part_no_calendars">No calendars found</string>
<string name="preference_clockwidget_calendar_part_manage_calendars">Manage calendars</string>
<string name="clockwidget_calendar_part_all_day">all-day</string>
<plurals name="clockwidget_calendar_part_more_events">
<item quantity="one">+%d more event</item>

View File

@@ -59,6 +59,8 @@ data class LauncherSettingsData internal constructor(
val clockWidgetMusicPart: Boolean = true,
val clockWidgetDatePart: Boolean = true,
val clockWidgetCalendarPart: Boolean = false,
val clockWidgetCalendarPartExcludedCalendars: Set<String> = emptySet(),
val clockWidgetCalendarPartMaxRows: Int = 4,
val clockWidgetDynamicZoneSlots: Int = 1,
val clockWidgetFillHeight: Boolean = false,
val clockWidgetAlignment: ClockWidgetAlignment = ClockWidgetAlignment.Bottom,

View File

@@ -11,6 +11,10 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/** Bounds for the number of event rows the agenda shows before collapsing. */
const val MinCalendarPartRows = 1
const val MaxCalendarPartRows = 8
data class ClockWidgetParts(
val date: Boolean,
val music: Boolean = false,
@@ -61,6 +65,39 @@ class ClockWidgetSettings internal constructor(
}
}
/**
* Calendars the agenda does not show. Empty means "all calendars".
*/
val calendarPartExcludedCalendars
get() = launcherDataStore.data.map { it.clockWidgetCalendarPartExcludedCalendars }
.distinctUntilChanged()
fun setCalendarExcluded(calendarId: String, excluded: Boolean) {
launcherDataStore.update {
it.copy(
clockWidgetCalendarPartExcludedCalendars =
if (excluded) it.clockWidgetCalendarPartExcludedCalendars + calendarId
else it.clockWidgetCalendarPartExcludedCalendars - calendarId
)
}
}
/**
* Number of event rows in the agenda before it collapses into a "+N more events" row.
*/
val calendarPartMaxRows
get() = launcherDataStore.data
.map { it.clockWidgetCalendarPartMaxRows.coerceIn(MinCalendarPartRows, MaxCalendarPartRows) }
.distinctUntilChanged()
fun setCalendarPartMaxRows(rows: Int) {
launcherDataStore.update {
it.copy(
clockWidgetCalendarPartMaxRows = rows.coerceIn(MinCalendarPartRows, MaxCalendarPartRows)
)
}
}
fun setDatePart(datePart: Boolean) {
launcherDataStore.update {
it.copy(clockWidgetDatePart = datePart)