6 Commits

Author SHA1 Message Date
c7d3a64c34 Bump version to 1.40.2-typing.10
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
2026-09-17 08:56:57 +02:00
c824b100b6 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.
2026-09-17 08:56:55 +02:00
c56ea58fc5 Use || instead of comma conditions in the subjectless when
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Build Nightly APK / build (push) Has been cancelled
2026-09-12 15:44:06 +02:00
b0072f0fc2 Key the weather worker's location skip on the provider, not on a setting
typing.8 checked WeatherSettingsData.managedLocation, but that only exists once the
user has explicitly picked "managed location" for the provider, so
weatherProviderSettings stayed empty and the guard never fired: the worker kept
requesting a location and retrying every 30s.

The real signal is the provider's own capability. BreezyWeatherProvider.getWeatherData()
is a hardcoded noop that also manages its own location, so add WeatherProvider.isPushBased
and check that instead.

Also stop treating a managed location as "pull nothing": those providers still have data
to fetch, they just resolve the position themselves, so they now take the
WeatherLocation path instead of the auto-location one.

Bump to 1.40.2-typing.9 (versionCode 2026091202).
2026-09-12 15:43:10 +02:00
61f3424de5 Stop the weather worker pinning location for providers that manage it
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Breezy Weather resolves its own location and pushes weather updates, but
WeatherUpdateWorker only looked at autoLocation before requesting a location.
BreezyWeatherProvider.getWeatherData() is a noop that always returns null, so
the worker then returned Result.retry() forever and never set lastUpdate -
which meant the update interval check could never short-circuit it either.

Every attempt called getLastKnownLocation(), which registers GPS *and* network
listeners at a one second interval and held them for up to ten minutes, so the
location stack never idled. On the phone this showed up as ~46k delivered fixes,
`*location*` wakelocks held ~99% of the time and Doze never engaging at all.

- expose the selected provider's managedLocation in WeatherSettingsData
- skip and cancel the weather work for providers that manage their own location
- give up on a fix after two minutes instead of ten
- LocationsRepository: don't collect the location flow when location search is
  off or not permitted. As a combineTransform argument it was always collected,
  so every search query registered GPS and network listeners for up to 30s.

Bump to 1.40.2-typing.8 (versionCode 2026091201).
2026-09-12 15:19:10 +02:00
45ac29e8a7 Document the desktop's actual build setup and the release procedure
Some checks failed
Build Nightly APK / build (push) Has been cancelled
The desktop (jonas@192.168.1.86) has no ~/jdk21 and no ~/android-sdk; its
checkout is ~/sources/Kvaesitso, the SDK is ~/Android/Sdk and it builds with
Arch's java-21-openjdk. Also record how the APK gets published, since that
was only in the release checklist of these notes.
2026-09-11 21:31:02 +02:00
15 changed files with 464 additions and 69 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.
@@ -87,7 +98,7 @@ state in `ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt`. New strings a
On the desktop:
JAVA_HOME=~/jdk21 ANDROID_HOME=~/android-sdk \
JAVA_HOME=/usr/lib/jvm/java-21-openjdk ANDROID_HOME=~/Android/Sdk \
./gradlew :app:app:assembleDefaultRelease
Result: `app/app/build/outputs/apk/default/release/app-default-release.apk`, applicationId
@@ -99,6 +110,21 @@ in the repository root (git-ignored) or the `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`
`~/android-keystores/kvaesitso-release.jks`; the phone has its own copy under
`~/android-keystores/` in the Termux home.
The desktop's checkout (jonas@192.168.1.86) is `~/sources/Kvaesitso`. Its SDK is `~/Android/Sdk`
(platforms android-35/37.0, build-tools 34/36/37), so `local.properties` there has
`sdk.dir=/home/jonas/Android/Sdk`, and `keystore.properties` points at the copy of the release key
in `~/android-keystores/`. It has no `~/jdk21`; it uses Arch's `java-21-openjdk`.
After the APK is built (on either machine), the release is tagged and published from the phone:
git tag -a v1.40.2-typing.N -m "Kvaesitso 1.40.2 type-to-search and agenda, Nth build"
git push origin main v1.40.2-typing.N
and a Gitea release for that tag is created with the APK attached as
`Kvaesitso-v<versionName>-Signed.apk` (the token is in `~/.config/gitea/token`; the API is
`POST /api/v1/repos/jonas/Kvaesitso/releases` and then `.../releases/<id>/assets?name=<asset>` with
`-F attachment=@<apk>`).
### Building on the phone itself (Termux)
The phone builds this project without a desktop. `scripts/ondevice.sh` wraps the loop:

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() ?: 2026091006
versionName = "1.40.2-typing.7"
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091701
versionName = "1.40.2-typing.10"
signingConfig = signingConfigs.getByName("debug")
}

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)

View File

@@ -38,6 +38,13 @@ data class WeatherSettingsData(
val lastLocation: LatLon? = null,
val lastUpdate: Long = 0L,
val providerSettings: Map<String, ProviderSettings> = emptyMap(),
/**
* Whether the selected provider resolves its own location (e.g. Breezy Weather, which is
* configured with a [WeatherLocation.Managed]). The launcher must then not request a location
* for it.
*/
val managedLocation: Boolean = false,
)
class WeatherSettings internal constructor(
@@ -53,6 +60,7 @@ class WeatherSettings internal constructor(
lastLocation = it.weatherLastLocation,
lastUpdate = it.weatherLastUpdate,
providerSettings = it.weatherProviderSettings,
managedLocation = it.weatherProviderSettings[it.weatherProvider]?.managedLocation == true,
)
}.distinctUntilChanged()
) {

View File

@@ -12,11 +12,15 @@ import de.mm20.launcher2.search.SearchableRepository
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.timeout
@@ -32,7 +36,7 @@ internal class LocationsRepository(
private val permissionsManager: PermissionsManager,
) : SearchableRepository<Location> {
@OptIn(FlowPreview::class)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
override fun search(
query: String,
allowNetwork: Boolean
@@ -40,50 +44,71 @@ internal class LocationsRepository(
if (query.isBlank() || query.length <= 1) {
return flowOf(persistentListOf())
}
return combineTransform(
poseProvider
.getLocation(minTimeMs = 2000, minDistanceM = 50.0f)
// 1st location: lastCachedLocation of poseProvider, if available
// 2nd location: LocationManager.getLastKnownLocation(), if available and better than lastCachedLocation
// 3rd location: live location from LocationManager.requestLocationUpdates() that is better than any of the previous
.take(3)
// only request locations for 30 seconds
.timeout(30.seconds),
return combine(
permissionsManager.hasPermission(PermissionGroup.Location),
settings.data
) { userLocation, hasPermission, settingsData ->
emit(persistentListOf())
if (!hasPermission || settingsData.providers.isEmpty()) {
return@combineTransform
}
val providers = settingsData.providers.map {
when (it) {
"openstreetmaps" -> OsmLocationProvider(context, settings)
else -> PluginLocationProvider(context, it)
}
}
supervisorScope {
val result = MutableStateFlow(persistentListOf<Location>())
for (provider in providers) {
launch {
val r = provider.search(
query,
userLocation,
allowNetwork,
settingsData.searchRadius,
settingsData.hideUncategorized
)
result.update {
(it + r).toPersistentList()
}
}
}
emitAll(result)
) { hasPermission, settingsData ->
!hasPermission || settingsData.providers.isEmpty()
}.distinctUntilChanged().flatMapLatest { unusable ->
if (unusable) {
// Bail out before building the location flow below. As an argument to
// combineTransform it is always collected, which registered GPS and network
// listeners for every search query even when location search was off or not
// permitted.
flowOf(persistentListOf())
} else {
locationSearchFlow(query, allowNetwork)
}
}
}
@OptIn(FlowPreview::class)
private fun locationSearchFlow(
query: String,
allowNetwork: Boolean,
): Flow<ImmutableList<Location>> = combineTransform(
poseProvider
.getLocation(minTimeMs = 2000, minDistanceM = 50.0f)
// 1st location: lastCachedLocation of poseProvider, if available
// 2nd location: LocationManager.getLastKnownLocation(), if available and better than lastCachedLocation
// 3rd location: live location from LocationManager.requestLocationUpdates() that is better than any of the previous
.take(3)
// only request locations for 30 seconds
.timeout(30.seconds),
permissionsManager.hasPermission(PermissionGroup.Location),
settings.data
) { userLocation, hasPermission, settingsData ->
emit(persistentListOf())
if (!hasPermission || settingsData.providers.isEmpty()) {
return@combineTransform
}
val providers = settingsData.providers.map {
when (it) {
"openstreetmaps" -> OsmLocationProvider(context, settings)
else -> PluginLocationProvider(context, it)
}
}
supervisorScope {
val result = MutableStateFlow(persistentListOf<Location>())
for (provider in providers) {
launch {
val r = provider.search(
query,
userLocation,
allowNetwork,
settingsData.searchRadius,
settingsData.hideUncategorized
)
result.update {
(it + r).toPersistentList()
}
}
}
emitAll(result)
}
}
}

View File

@@ -11,6 +11,14 @@ interface WeatherProvider {
return 1000 * 60 * 60L
}
/**
* Whether this provider pushes weather updates on its own instead of being polled, and
* resolves its location itself. [WeatherUpdateWorker] then has nothing to pull and must not
* request a location for it.
*/
val isPushBased: Boolean
get() = false
suspend fun getWeatherData(location: WeatherLocation): List<Forecast>?
suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>?
suspend fun findLocation(query: String): List<WeatherLocation>

View File

@@ -79,7 +79,12 @@ internal class WeatherRepositoryImpl(
}
scope.launch {
settings.collectLatest {
val provider = WeatherProvider.getInstance(it.provider)
val provider = WeatherProvider.getInstance(it.provider)
if (provider.isPushBased) {
// The provider sends its updates itself, so there is nothing to poll for.
WorkManager.getInstance(context).cancelUniqueWork("weather")
return@collectLatest
}
val weatherRequest =
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMillis(provider.getUpdateInterval()))
.build()
@@ -214,6 +219,17 @@ class WeatherUpdateWorker(
val settingsData = settings.first()
val provider = WeatherProvider.getInstance(settingsData.provider)
// A push-based provider sends its updates on its own and resolves its own location
// (Breezy Weather does both), so there is nothing to pull here. Its getWeatherData()
// always returns null, which used to make this worker request a location - registering
// GPS and network listeners and holding them for up to ten minutes - and then return
// Result.retry() forever, without ever setting lastUpdate, so the interval check below
// could never short-circuit it either.
if (provider.isPushBased) {
Log.d("WeatherUpdateWorker", "Provider pushes updates, nothing to pull")
return Result.success()
}
val updateInterval = provider.getUpdateInterval()
val lastUpdate = settingsData.lastUpdate
@@ -222,21 +238,26 @@ class WeatherUpdateWorker(
return Result.failure()
}
val weatherData = if (settingsData.autoLocation) {
val latLon = getLastKnownLocation() ?: settingsData.lastLocation
if (latLon == null) {
Log.e("WeatherUpdateWorker", "Could not get location")
return Result.failure()
val weatherData = when {
// A managed location means the provider resolves the position itself, so requesting
// one here would be wasted work that holds the location stack open.
settingsData.managedLocation || !settingsData.autoLocation -> {
val location = settings.location.first()
if (location == null) {
Log.e("WeatherUpdateWorker", "Location not set")
return Result.failure()
}
provider.getWeatherData(location)
}
settings.setLastLocation(latLon)
provider.getWeatherData(latLon.lat, latLon.lon)
} else {
val location = settings.location.first()
if (location == null) {
Log.e("WeatherUpdateWorker", "Location not set")
return Result.failure()
else -> {
val latLon = getLastKnownLocation() ?: settingsData.lastLocation
if (latLon == null) {
Log.e("WeatherUpdateWorker", "Could not get location")
return Result.failure()
}
settings.setLastLocation(latLon)
provider.getWeatherData(latLon.lat, latLon.lon)
}
provider.getWeatherData(location)
}
return if (weatherData == null) {
@@ -254,7 +275,10 @@ class WeatherUpdateWorker(
@OptIn(FlowPreview::class)
private suspend fun getLastKnownLocation(): LatLon? = locationProvider.getLocation(skipCache = true)
.timeout(10.minutes)
// Weather does not need a metre-accurate fix; giving up after two minutes instead of ten
// keeps the GPS and network listeners (and the wakelocks they hold) short. The caller
// falls back to the last known location.
.timeout(2.minutes)
.firstOrNull()
.or { locationProvider.lastCachedLocation }
?.let { LatLon(it.latitude, it.longitude) }

View File

@@ -20,6 +20,9 @@ class BreezyWeatherProvider(
) : WeatherProvider, KoinComponent {
private val database: AppDatabase by inject()
// Breezy sends its weather data to us (see pushWeatherData) and manages its own location.
override val isPushBased: Boolean = true
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
// Noop implementation, because Breezy weather is handled in a special way
return null