Show an agenda in the clock widget's dynamic zone, with multiple slots

The dynamic zone showed exactly one part: the highest ranked of the enabled
ones. It now shows up to `clockWidgetDynamicZoneSlots` of them (default 1),
adjustable with a slider in the clock widget settings whose maximum is the
number of enabled parts. `ClockWidgetVM.getActiveParts()` returns that sorted
list, and both clock layouts render it (stacked under the clock, or next to it
in the horizontal layout).

On top of that the zone has an "Events" part (`clockWidgetCalendarPart`, off by
default) that shows today's agenda: events that are still running or upcoming
today, plus today's all-day events. At most four rows; when there are more, the
last row is a "+N more events" hint that opens the calendar app. Tapping a row
opens the event through `CalendarEvent.launch()`.

A row is three fixed-width columns that are pure layout, not drawn: a dot in the
calendar's colour, the time, and the title (ellipsized when it does not fit).
Times are always times, never dates - an all-day event renders "all-day" - and
the block is centred under the clock. The columns line up across rows, and the
time is left-aligned within its column.

The part ranks 70 when an event is running or starts within half an hour, 30
while events are left today and 0 on an empty day, so the date keeps the slot
when there is nothing to show. It reloads itself through a ContentObserver on
CalendarContract.Events and a 15 minute ticker, because unlike the calendar
widget it is on screen all the time. Tasks (`CalendarEvent.isTask`) are filtered
out for now; a tasks part can reuse this once tasks are configured.

Verified on the Titan 2: with the part enabled and three slots, the home screen
shows the agenda (two all-day events and a timed one) above the date part.
This commit is contained in:
2026-09-11 12:25:09 +02:00
parent 3f677e2c87
commit 6ae31fe92b
8 changed files with 448 additions and 15 deletions

View File

@@ -1,4 +1,4 @@
# Kvaesitso (fork with type-to-search)
# Kvaesitso (fork with type-to-search and a home-screen agenda)
Fork of [Kvaesitso](https://github.com/MM2-0/Kvaesitso) that adds type-to-search for phones with a
physical keyboard. Built for, and installed on, a Unihertz Titan 2 Elite.
@@ -45,6 +45,44 @@ text.
The search is also cleared whenever the launcher is left, even for a short app switch — upstream
keeps it for returns within five seconds, which leaves a stale query on screen.
## The clock widget's dynamic zone (agenda part and multiple slots)
Upstream's home-screen clock has one "dynamic zone" below (or beside) the clock. Every enabled
*part* (`DatePartProvider`, `MusicPartProvider`, `BatteryPartProvider`, `AlarmPartProvider`) reports
a `getRanking()`; only the single highest-ranked part with a ranking above zero is shown. Two
changes on top of that:
* **The zone can show more than one part.** The setting `clockWidgetDynamicZoneSlots` (1..number of
enabled parts) caps how many of the highest-ranked parts are rendered;
`ClockWidgetVM.getActiveParts()` returns that sorted list instead of a single provider. The
slider lives in `ConfigureClockWidgetSheet` next to the part toggles.
* **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
(`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".
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
the slot on an empty day); alarms 60 (ringing within 8 h); media 80 while playing; battery 55 when
low and 10 when charging or set to "always"; date 1. Tasks (`CalendarEvent.isTask`) are filtered out
of the agenda; a tasks part is planned but not implemented.
`CalendarPartProvider` keeps itself up to date with a `ContentObserver` on
`CalendarContract.Events.CONTENT_URI` plus a 15-minute ticker (the other calendar consumers in the
launcher only re-query when they become visible). `PartProvider.setTime()` is called every second
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
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.
## Building
On the desktop:

View File

@@ -63,6 +63,7 @@ import de.mm20.launcher2.ui.component.DismissableBottomSheet
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.component.preferences.ListPreference
import de.mm20.launcher2.ui.component.preferences.SliderPreference
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.AnalogClock
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.BinaryClock
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.CustomClock
@@ -105,8 +106,8 @@ fun ClockWidget(
viewModel.updateTime(time)
}
val partProvider by remember { viewModel.getActivePart(context) }.collectAsStateWithLifecycle(
null
val partProviders by remember { viewModel.getActiveParts(context) }.collectAsStateWithLifecycle(
emptyList()
)
AnimatedContent(editMode, label = "ClockWidget") {
@@ -181,11 +182,11 @@ fun ClockWidget(
Clock(clockStyle, false, darkColors)
}
if (partProvider != null) {
partProviders.forEach { provider ->
DynamicZone(
modifier = Modifier.padding(bottom = 8.dp),
compact = false,
provider = partProvider,
provider = provider,
)
}
}
@@ -198,12 +199,13 @@ fun ClockWidget(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
if (partProvider != null) {
DynamicZone(
modifier = Modifier.weight(1f),
compact = true,
provider = partProvider,
)
Column(modifier = Modifier.weight(1f)) {
partProviders.forEach { provider ->
DynamicZone(
compact = true,
provider = provider,
)
}
}
if (clockStyle !is ClockWidgetStyle.Empty) {
Box(
@@ -362,6 +364,8 @@ fun ConfigureClockWidgetSheet(
val useAccentColor by viewModel.useThemeColor.collectAsState()
val parts by viewModel.parts.collectAsState()
val smartspacer by viewModel.useSmartspacer.collectAsState()
val slots by viewModel.slots.collectAsState()
val partCount by viewModel.partCount.collectAsState()
Column(
modifier = Modifier
@@ -668,6 +672,15 @@ fun ConfigureClockWidgetSheet(
viewModel.setAlarmPart(it)
}
)
SwitchPreference(
title = stringResource(R.string.preference_clockwidget_calendar_part),
summary = stringResource(R.string.preference_clockwidget_calendar_part_summary),
icon = R.drawable.event_24px,
value = parts?.calendar == true,
onValueChanged = {
viewModel.setCalendarPart(it)
}
)
ListPreference(
title = stringResource(R.string.preference_clockwidget_battery_part),
icon = R.drawable.battery_full_24px,
@@ -681,6 +694,18 @@ fun ConfigureClockWidgetSheet(
stringResource(R.string.preference_clockwidget_battery_part_always_show) to BatteryStatusVisibility.Always
)
)
SliderPreference(
title = stringResource(R.string.preference_clockwidget_dynamic_zone_slots),
icon = R.drawable.dashboard_2_24px,
value = slots,
min = 1,
max = maxOf(1, partCount),
step = 1,
enabled = partCount > 1,
onValueChanged = {
viewModel.setSlots(it)
}
)
}
}
}

View File

@@ -10,6 +10,7 @@ import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.AlarmPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.BatteryPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.CalendarPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.DatePartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.FavoritesPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.MusicPartProvider
@@ -37,21 +38,27 @@ class ClockWidgetVM : ViewModel(), KoinComponent {
val providers = mutableListOf<PartProvider>()
if (parts.date) providers += DatePartProvider()
if (parts.calendar) providers += CalendarPartProvider()
if (parts.music) providers += MusicPartProvider()
providers += BatteryPartProvider(parts.battery)
if (parts.alarm) providers += AlarmPartProvider()
providers
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
fun getActivePart(context: Context): Flow<PartProvider?> = channelFlow {
fun getActiveParts(context: Context): Flow<List<PartProvider>> = channelFlow {
partProviders.collectLatest { providers ->
if (providers.isEmpty()) {
send(null)
send(emptyList())
return@collectLatest
}
val rankings = providers.map { it.getRanking(context).map { r -> r to it } }
combine(rankings) { r ->
r.filter { it.first > 0 }.maxByOrNull { it.first }?.second
val ranked = combine(rankings) { r ->
r.filter { it.first > 0 }
.sortedByDescending { it.first }
.map { it.second }
}
combine(ranked, settings.slots) { r, slots ->
r.take(slots.coerceAtLeast(1))
}.collectLatest {
send(it)
}

View File

@@ -0,0 +1,305 @@
package de.mm20.launcher2.ui.launcher.widgets.clock.parts
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.database.ContentObserver
import android.icu.text.DateFormat
import android.icu.util.ULocale
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.ktx.tryStartActivity
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.search.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.locals.LocalTimeFormat
import de.mm20.launcher2.ui.utils.isTwentyFourHours
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.time.LocalDate
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.
*/
private const val MaxRows = 4
/** An event that starts within this time frame (or is running) raises the ranking. */
private const val SoonThreshold = 30 * 60 * 1000L
/** Re-query interval, so the agenda survives calendar changes that don't notify. */
private const val RefreshInterval = 15 * 60 * 1000L
private const val RankingToday = 30
private const val RankingSoon = 70
/** Width of the dot column. Kept fixed so the rows line up with each other. */
private val DotColumn = 12.dp
/** Width of the time column. Wide enough for "11:59 PM" and "all-day". */
private val TimeColumn = 64.dp
/** Width of the title column. Titles longer than this are ellipsized. */
private val TitleColumn = 176.dp
/**
* Dynamic zone part that shows today's agenda.
*
* Only events that are still running or upcoming today are shown, plus all-day events of
* today. Tasks are ignored for now.
*/
class CalendarPartProvider : PartProvider, KoinComponent {
private val calendarRepository: CalendarRepository by inject()
private val permissionsManager: PermissionsManager by inject()
private val searchSettings: CalendarSearchSettings by inject()
private val agenda = MutableStateFlow<List<CalendarEvent>>(emptyList())
private val time = MutableStateFlow(System.currentTimeMillis())
override fun setTime(time: Long) {
this.time.value = time
}
override fun getRanking(context: Context): Flow<Int> = channelFlow {
// Load the agenda in the background; the ranking itself only depends on the
// already known agenda and the current time, so it can be emitted right away.
launch { observeAgenda(context) }
combine(agenda, time) { events, now -> ranking(events, now) }
.distinctUntilChanged()
.collect { send(it) }
}
private suspend fun observeAgenda(context: Context) {
combine(
dayChanges(context),
permissionsManager.hasPermission(PermissionGroup.Calendar),
searchSettings.excludedCalendars,
) { now, hasPermission, excludedCalendars -> Triple(now, hasPermission, excludedCalendars) }
.collectLatest { (now, hasPermission, excludedCalendars) ->
agenda.value = if (!hasPermission) {
emptyList()
} else withContext(Dispatchers.IO) {
calendarRepository.findMany(
from = now,
to = endOfDay(now),
excludeCalendars = excludedCalendars.toList(),
).first()
.filter { !it.isTask }
.sortedBy { it.startTime ?: it.endTime }
}
}
}
/**
* Emits the current time when the calendar database changes and periodically, so the
* agenda is re-queried when events change or the day rolls over.
*/
private fun dayChanges(context: Context): Flow<Long> = callbackFlow {
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
trySend(System.currentTimeMillis())
}
}
val registered = try {
context.contentResolver.registerContentObserver(
CalendarContract.Events.CONTENT_URI, true, observer
)
true
} catch (e: SecurityException) {
false
}
trySend(System.currentTimeMillis())
val ticker = launch {
while (isActive) {
delay(RefreshInterval)
trySend(System.currentTimeMillis())
}
}
awaitClose {
ticker.cancel()
if (registered) context.contentResolver.unregisterContentObserver(observer)
}
}
private fun ranking(events: List<CalendarEvent>, now: Long): Int {
if (events.isEmpty()) return 0
val soon = events.any { event ->
val start = event.startTime
!event.allDay && start != null && start <= now + SoonThreshold && event.endTime > now
}
return if (soon) RankingSoon else RankingToday
}
private fun endOfDay(now: Long): Long {
val offset = OffsetDateTime.now().offset
return LocalDate.now().plusDays(1).atStartOfDay().toEpochSecond(offset) * 1000
}
@Composable
override fun Component(compactLayout: Boolean) {
val events by agenda.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
val hiddenCount = events.size - visibleCount
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
for (event in events.take(visibleCount)) {
AgendaRow(
color = event.color?.let { Color(it) } ?: contentColor,
time = event.timeLabel(context, timeFormat),
title = event.label,
onClick = { event.launch(context, null) },
)
}
if (hiddenCount > 0) {
AgendaRow(
color = null,
time = null,
title = pluralStringResource(
R.plurals.clockwidget_calendar_part_more_events,
hiddenCount,
hiddenCount
),
secondary = true,
onClick = { openCalendar(context) },
)
}
}
}
}
/**
* The time of an event, or "all-day" for all-day events. Never a date.
*/
private fun CalendarEvent.timeLabel(context: Context, timeFormat: TimeFormat): String {
if (allDay) return context.getString(R.string.clockwidget_calendar_part_all_day)
val start = startTime ?: return ""
val skeleton = if (timeFormat.isTwentyFourHours(context)) "HH:mm" else "hh:mm a"
return DateFormat.getInstanceForSkeleton(skeleton, ULocale.getDefault()).format(Date(start))
}
/**
* One agenda row: a dot in the calendar color, the time and the title. The three columns are
* plain layout, they are not drawn.
*/
@Composable
private fun AgendaRow(
color: Color?,
time: String?,
title: String,
secondary: Boolean = false,
onClick: () -> Unit,
) {
val contentColor = LocalContentColor.current
val secondaryColor = contentColor.copy(alpha = contentColor.alpha * 0.7f)
Row(
modifier = Modifier
.clickable(onClick = onClick)
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.width(DotColumn),
contentAlignment = Alignment.Center,
) {
if (color != null) {
Box(
modifier = Modifier
.size(8.dp)
.background(color, CircleShape)
)
}
}
Box(
modifier = Modifier.width(TimeColumn),
contentAlignment = Alignment.CenterStart,
) {
if (time != null) {
Text(
text = time,
style = MaterialTheme.typography.labelLarge,
color = secondaryColor,
maxLines = 1,
)
}
}
Box(
modifier = Modifier.width(TitleColumn),
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = if (secondary) secondaryColor else contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private fun openCalendar(context: Context) {
val builder = CalendarContract.CONTENT_URI.buildUpon()
builder.appendPath("time")
ContentUris.appendId(builder, System.currentTimeMillis())
context.tryStartActivity(
Intent(Intent.ACTION_VIEW)
.setData(builder.build())
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}

View File

@@ -11,6 +11,7 @@ import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
import de.mm20.launcher2.preferences.ui.UiSettings
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -101,6 +102,32 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
settings.setAlarmPart(alarmPart)
}
fun setCalendarPart(calendarPart: Boolean) {
settings.setCalendarPart(calendarPart)
}
val slots = settings.slots
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
fun setSlots(slots: Int) {
settings.setSlots(slots)
}
/**
* Number of enabled parts. This is the upper bound for the dynamic zone's slot count.
*/
val partCount = settings.parts
.map { parts ->
var count = 0
if (parts.date) count++
if (parts.music) count++
if (parts.alarm) count++
if (parts.calendar) count++
if (parts.battery != BatteryStatusVisibility.Hide) count++
count
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
val alignment = settings.alignment
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)

View File

@@ -598,6 +598,7 @@
<string name="preference_clock_widget_alignment_center">Center</string>
<string name="preference_clock_widget_alignment_bottom">Bottom</string>
<string name="preference_clockwidget_dynamic_zone">Dynamic zone</string>
<string name="preference_clockwidget_dynamic_zone_slots">Visible parts</string>
<string name="preference_clockwidget_smartspacer">Managed by Smartspacer</string>
<string name="preference_clockwidget_date_part">Date</string>
<string name="preference_clockwidget_date_part_summary">Show the current date</string>
@@ -612,6 +613,13 @@
<string name="preference_clockwidget_battery_part_hide">Off</string>
<string name="preference_clockwidget_alarm_part">Alarms</string>
<string name="preference_clockwidget_alarm_part_summary">Show alarms that will ring within the next 8 hours</string>
<string name="preference_clockwidget_calendar_part">Events</string>
<string name="preference_clockwidget_calendar_part_summary">Show today\'s agenda</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>
<item quantity="other">+%d more events</item>
</plurals>
<string name="preference_screen_backup">Backup and restore</string>
<string name="preference_screen_backup_summary">Export and import launcher data</string>
<string name="preference_backup">Backup</string>

View File

@@ -58,6 +58,8 @@ data class LauncherSettingsData internal constructor(
val clockWidgetBatteryPart: BatteryStatusVisibility = BatteryStatusVisibility.Show,
val clockWidgetMusicPart: Boolean = true,
val clockWidgetDatePart: Boolean = true,
val clockWidgetCalendarPart: Boolean = false,
val clockWidgetDynamicZoneSlots: Int = 1,
val clockWidgetFillHeight: Boolean = false,
val clockWidgetAlignment: ClockWidgetAlignment = ClockWidgetAlignment.Bottom,

View File

@@ -16,6 +16,7 @@ data class ClockWidgetParts(
val music: Boolean = false,
val battery: BatteryStatusVisibility = BatteryStatusVisibility.Hide,
val alarm: Boolean = false,
val calendar: Boolean = false,
)
class ClockWidgetSettings internal constructor(
@@ -37,9 +38,29 @@ class ClockWidgetSettings internal constructor(
music = it.clockWidgetMusicPart,
battery = it.clockWidgetBatteryPart,
alarm = it.clockWidgetAlarmPart,
calendar = it.clockWidgetCalendarPart,
)
}.distinctUntilChanged()
/**
* Maximum number of parts the dynamic zone shows at once.
*/
val slots
get() = launcherDataStore.data.map { it.clockWidgetDynamicZoneSlots.coerceAtLeast(1) }
.distinctUntilChanged()
fun setSlots(slots: Int) {
launcherDataStore.update {
it.copy(clockWidgetDynamicZoneSlots = slots.coerceAtLeast(1))
}
}
fun setCalendarPart(calendarPart: Boolean) {
launcherDataStore.update {
it.copy(clockWidgetCalendarPart = calendarPart)
}
}
fun setDatePart(datePart: Boolean) {
launcherDataStore.update {
it.copy(clockWidgetDatePart = datePart)