4 Commits

Author SHA1 Message Date
291752abcd Bump version to 1.40.2-typing.7
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
The clock widget's agenda part is immediate and reliable now: its query is
no longer held back by the repository's 500 ms debounce, collectors no longer
receive an empty list before the provider has run, and the parts are not
rebuilt (losing their cached data) every time the launcher leaves the
background.
2026-09-11 21:21:28 +02:00
4836618609 Stop calendar queries emitting an empty list before they have run
`queryCalendarEvents` forwarded the `MutableStateFlow` it accumulates
the provider results in, so the initial empty value was the first thing
collectors saw - before any provider had reported. Callers in the
launcher take that first result (`CalendarPartProvider` uses `.first()`)
and cannot tell it apart from "no events", so the agenda stayed empty
and its part never showed up.

`CalendarRepository.findMany`'s 500 ms debounce used to hide this: the
placeholder was superseded by the real list within the debounce window,
which is exactly why the agenda only ever appeared half a second late.

Keep the accumulator null until the first provider reports, and let
`findMany` - a one-shot query for the clock and calendar widgets - wait
for all providers (`awaitAllProviders`) so it never hands out a partial
list. The search path keeps streaming results as providers report, now
without an empty flash.
2026-09-11 21:17:57 +02:00
c8109d9197 Keep clock widget parts alive across lifecycle stops
`partProviders` was a `stateIn(WhileSubscribed)` flow, so its upstream
map ran again every time the clock widget was subscribed after the
launcher had been in the background - and built new part providers.
Providers cache what their ranking is based on, so the new agenda part
started at ranking 0, vanished while the launcher was gone and only came
back once its query had run again.

Build the provider list in `viewModelScope` into a `MutableStateFlow`
instead, so the instances (and their cached state) survive for as long
as the ViewModel does. Their ranking flows are still only collected
while the widget is subscribed, so observers are still registered and
unregistered with the widget's lifecycle.
2026-09-11 21:01:03 +02:00
3b3d6473ca Don't debounce the agenda query in the clock widget
`CalendarRepository.findMany` holds every result back by 500 ms, which
was added for the calendar widget to collapse bursts of plugin and
permission changes. `debounce` delays the first result too, so the
agenda part - whose ranking stays 0 until the first result arrives -
only appeared half a second after the clock and the other parts.

The agenda is re-queried on a calendar change or every 15 minutes, so
it does not need the debounce. Make the delay a parameter (still 500 ms
by default) and pass 0 from `CalendarPartProvider`.
2026-09-11 21:01:00 +02:00
4 changed files with 70 additions and 30 deletions

View File

@@ -34,8 +34,8 @@ android {
applicationId = "de.mm20.launcher2"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091005
versionName = "1.40.2-typing.6"
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091006
versionName = "1.40.2-typing.7"
signingConfig = signingConfigs.getByName("debug")
}

View File

@@ -17,33 +17,47 @@ import de.mm20.launcher2.ui.launcher.widgets.clock.parts.MusicPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.PartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.SmartspacerPartProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class ClockWidgetVM : ViewModel(), KoinComponent {
private val settings: ClockWidgetSettings by inject()
private val partProviders = settings.parts.combine(settings.useSmartspacer) { p, s ->
p to s
}.map { (parts, smartspacer) ->
if (smartspacer && isAtLeastApiLevel(29)) {
return@map listOf(SmartspacerPartProvider())
}
/**
* Kept for the lifetime of the ViewModel instead of being rebuilt whenever the clock widget
* is subscribed again - which happens on every return to the home screen, because the
* launcher stops while it is in the background. Providers cache the data behind their
* ranking, so a fresh instance starts at ranking 0 and makes its part disappear until its
* query returns; the agenda part would blink on every visit that way.
*/
private val partProviders = MutableStateFlow<List<PartProvider>>(emptyList())
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())
init {
viewModelScope.launch {
settings.parts.combine(settings.useSmartspacer) { p, s -> p to s }
.collect { (parts, smartspacer) ->
partProviders.value = if (smartspacer && isAtLeastApiLevel(29)) {
listOf(SmartspacerPartProvider())
} else {
buildList {
if (parts.date) add(DatePartProvider())
if (parts.calendar) add(CalendarPartProvider())
if (parts.music) add(MusicPartProvider())
add(BatteryPartProvider(parts.battery))
if (parts.alarm) add(AlarmPartProvider())
}
}
}
}
}
fun getActiveParts(context: Context): Flow<List<PartProvider>> = channelFlow {
partProviders.collectLatest { providers ->

View File

@@ -122,6 +122,10 @@ class CalendarPartProvider : PartProvider, KoinComponent {
from = now,
to = endOfDay(now),
excludeCalendars = excludedCalendars.toList(),
// No debounce: the ranking stays 0 until the first result arrives, and
// the query is only re-run on a calendar change or every 15 minutes, so
// holding the result back would only delay the part's appearance.
debounceMillis = 0,
).first()
.filter { !it.isTask }
.sortedBy { it.startTime ?: it.endTime }

View File

@@ -22,20 +22,28 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlin.time.Duration.Companion.days
interface CalendarRepository : SearchableRepository<CalendarEvent> {
/**
* @param debounceMillis how long each result is held back, so bursts of upstream changes
* (plugin or permission updates) cause a single query. [debounce] delays the first result
* too, so callers that must show something immediately pass 0.
*/
fun findMany(
from: Long = System.currentTimeMillis(),
to: Long = from + 14 * 24 * 60 * 60 * 1000L,
excludeCalendars: List<String> = emptyList(),
excludeAllDayEvents: Boolean = false,
debounceMillis: Long = 500,
): Flow<ImmutableList<CalendarEvent>>
fun getCalendars(providerId: String? = null): Flow<List<CalendarList>>
@@ -94,6 +102,7 @@ internal class CalendarRepositoryImpl(
to: Long,
excludeCalendars: List<String>,
excludeAllDayEvents: Boolean,
debounceMillis: Long,
): Flow<ImmutableList<CalendarEvent>> {
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks)
@@ -112,17 +121,17 @@ internal class CalendarRepositoryImpl(
)
}
emitAll(
queryCalendarEvents(
query = null,
intervalStart = from,
intervalEnd = to,
excludeAllDayEvents = excludeAllDayEvents,
excludeCalendars = excludeCalendars,
providers = providers,
allowNetwork = false,
).debounce(500)
val events = queryCalendarEvents(
query = null,
intervalStart = from,
intervalEnd = to,
excludeAllDayEvents = excludeAllDayEvents,
excludeCalendars = excludeCalendars,
providers = providers,
awaitAllProviders = true,
allowNetwork = false,
)
emitAll(if (debounceMillis > 0L) events.debounce(debounceMillis) else events)
}
}
@@ -133,12 +142,17 @@ internal class CalendarRepositoryImpl(
excludeAllDayEvents: Boolean = false,
excludeCalendars: List<String> = emptyList(),
allowNetwork: Boolean = false,
awaitAllProviders: Boolean = false,
providers: List<CalendarProvider>,
): Flow<ImmutableList<CalendarEvent>> = flow {
supervisorScope {
val result = MutableStateFlow(persistentListOf<CalendarEvent>())
// Null until the first provider has reported. Emitting the (empty) accumulator right
// away would hand collectors an empty list before any query had run, which callers
// that take the first result - like the agenda in the clock widget - cannot tell
// apart from "no events".
val result = MutableStateFlow<ImmutableList<CalendarEvent>?>(null)
for (provider in providers) {
val jobs = providers.map { provider ->
launch {
val r = provider.search(
query,
@@ -152,11 +166,19 @@ internal class CalendarRepositoryImpl(
allowNetwork = allowNetwork,
)
result.update {
(it + r).toPersistentList()
((it ?: persistentListOf()) + r).toPersistentList()
}
}
}
emitAll(result)
if (awaitAllProviders) {
// One-shot callers must not receive a partial list, so wait until every
// provider has reported; if there is none, report an empty result.
jobs.joinAll()
emit(result.value ?: persistentListOf())
} else {
emitAll(result.filterNotNull())
}
}
}