diff --git a/CLAUDE.md b/CLAUDE.md index 987f97150..3801e92fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`, + 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: diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt index 179534452..1d4ddc356 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt @@ -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) + } + ) } } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidgetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidgetVM.kt index 2573191e8..1c58158b4 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidgetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidgetVM.kt @@ -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() 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 = channelFlow { + fun getActiveParts(context: Context): Flow> = 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) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/parts/CalendarPartProvider.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/parts/CalendarPartProvider.kt new file mode 100644 index 000000000..138cfcbe8 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/parts/CalendarPartProvider.kt @@ -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>(emptyList()) + private val time = MutableStateFlow(System.currentTimeMillis()) + + override fun setTime(time: Long) { + this.time.value = time + } + + override fun getRanking(context: Context): Flow = 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 = 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, 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) + ) +} diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt index cf4071798..57d938d77 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt @@ -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) diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 9c55ae9f6..f00c1c09b 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -598,6 +598,7 @@ Center Bottom Dynamic zone + Visible parts Managed by Smartspacer Date Show the current date @@ -612,6 +613,13 @@ Off Alarms Show alarms that will ring within the next 8 hours + Events + Show today\'s agenda + all-day + + +%d more event + +%d more events + Backup and restore Export and import launcher data Backup diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt index ca8267d7d..64b116aec 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt @@ -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, diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/ClockWidgetSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/ClockWidgetSettings.kt index b9cd509c6..7ae46a3a3 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/ClockWidgetSettings.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/ClockWidgetSettings.kt @@ -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)