8 Commits

Author SHA1 Message Date
6aba977823 Bump version to 1.40.2-typing.6
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Alarm shows its trigger time and matches the date part's typography, static
dynamic-zone rankings (alarm 100, date 90, calendar 30), and a wider agenda
dot column.
2026-09-11 13:23:13 +02:00
229e5d1d32 Widen the agenda dot column to 24dp for more dot/time spacing 2026-09-11 13:22:59 +02:00
cb07acb577 Match the alarm part's typography to the date part
The alarm text used the button's default labelLarge in the vertical layout
and titleMedium in the compact one. Use titleMedium / titleLarge(Medium),
the same styles the date part uses, so both parts look alike.
2026-09-11 13:22:59 +02:00
a0f9413d40 Clock dynamic zone: alarm shows trigger time, static rankings, wider dot gap
- Alarm part now shows the time the alarm goes off instead of a relative
  "in X hours" span, formatted per the device's 12/24h setting.
- Reorder the dynamic zone: alarm (100) always sits below the clock, date
  (90) follows it.
- Calendar part ranking is now static (30) instead of boosting events that
  start within 30 minutes; drop the now-unused time plumbing.
- Widen the agenda dot column from 12dp to 20dp for more dot/time spacing.
2026-09-11 13:13:35 +02:00
05c7f0f60d Show the next alarm 24 hours ahead, not 8
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
An alarm set for the next morning left the dynamic zone before it was visible:
at 22:00 an alarm at 06:00 is eight hours away, so the part only appeared while
the phone was unlikely to be looked at. The window is now 24 hours, and the
setting summary says so (also in Danish; the other translations still say 8 and
will follow upstream).

Verified on the Titan 2 that Chrono (com.vicolo.chrono) registers its alarms the
way this part reads them: dumpsys alarm lists them as "Alarm clock" entries with
a showIntent pointing at Chrono, and AlarmManager.nextAlarmClock returns the
earliest one, so ACTION_NEXT_ALARM_CLOCK_CHANGED keeps the part up to date.
Tapping it opens Chrono as well - SHOW_ALARMS, SET_ALARM and SHOW_TIMERS all
resolve to com.vicolo.chrono/.MainActivity.
2026-09-11 12:30:05 +02:00
6c6cbe33fa Bump version to 1.40.2-typing.5
The agenda part and the configurable number of dynamic zone slots, with the
time column left-aligned.
2026-09-11 12:25:17 +02:00
6ae31fe92b 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.
2026-09-11 12:25:09 +02:00
3f677e2c87 Build debug APKs on the phone itself
docs: the Termux home now has a working aarch64 Android SDK, so record
how to build here, how the debug build differs from the release one
(separate package, own data directory, so it cannot touch the installed
launcher's config), and warn about the destructive uninstall path.

scripts/ondevice.sh wraps the loop: build, install over the debug
package, launch, follow logcat, read the newest crash report, and build
the signed release APK.
2026-09-11 09:58:08 +02:00
13 changed files with 611 additions and 40 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,8 +45,48 @@ 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:
JAVA_HOME=~/jdk21 ANDROID_HOME=~/android-sdk \
./gradlew :app:app:assembleDefaultRelease
@@ -57,8 +97,56 @@ The `release` build type signs with the `local` signing config, which reads `key
in the repository root (git-ignored) or the `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`, `KEY_ALIAS` and
`KEY_PASSWORD` environment variables. The desktop key is
`~/android-keystores/kvaesitso-release.jks`; the phone has its own copy under
`~/android-keystores/` in the Termux home. Termux itself has no Android SDK, so build there only
after installing one (or build on the desktop and copy the APK over).
`~/android-keystores/` in the Termux home.
### Building on the phone itself (Termux)
The phone builds this project without a desktop. `scripts/ondevice.sh` wraps the loop:
scripts/ondevice.sh build # assemble the debug APK
scripts/ondevice.sh install # build, then pm install it (root)
scripts/ondevice.sh start # launch the debug build
scripts/ondevice.sh logs # follow logcat of the debug build (Ctrl-C to stop)
scripts/ondevice.sh crash # newest crash report for the debug build
scripts/ondevice.sh release # assemble the signed release APK
Or call Gradle directly: `./gradlew :app:app:assembleDefaultDebug`. A full build takes about 13
minutes (984 tasks), later ones 20-30 seconds; the release build (R8 plus `lintVital`) takes about
11 minutes. Run `termux-wake-lock` first so the screen lock does not throttle it.
The **debug** build is `de.mm20.launcher2.debug` (`isDebuggable`, its own empty data directory),
so it installs next to the real launcher and can be logged with
`su -c 'logcat --pid=$(pidof de.mm20.launcher2.debug)'`. Installing, reinstalling or uninstalling
that package cannot affect the real launcher: different package, different data directory.
> **Never uninstall `de.mm20.launcher2.release`.** It is the launcher the phone actually runs, and
> the config (widgets, pages, tags, search settings) lives in its data directory
> `/data/user/0/de.mm20.launcher2.release`, mostly under `files/datastore`. `pm install -r` of a
> build signed with the same key keeps that data, but it does replace the running launcher, so do it
> deliberately, after backing the data directory up. Reinstalling the *debug* build is always the
> safe way to try a change.
To try a fix in the real launcher with the real data, build the release APK and `pm install -r` it:
same package (`de.mm20.launcher2.release`) and same key, so it replaces the installed build in
place. The release APK is minified by R8, so this is the slow build. `scripts/ondevice.sh release`
only *builds* it; it never installs anything.
#### The SDK on the phone
`~/android-sdk` (see `~/CLAUDE.md`) provides everything Gradle needs:
* `platforms/android-37.0` — the compileSdk 37 platform, installed with `sdkmanager`.
* `build-tools/36.0.0` — the version AGP 9.3.1 defaults to. Google only ships x86_64 build-tools,
so the three native tools are symlinks into `build-tools/34.0.4`, which is AndroidIDE's aarch64
build of the AOSP tools: `aapt2`, `zipalign` and `aidl`. Everything else in build-tools
(`d8`, `apksigner`, jars) is Java and runs as is.
* `~/.gradle/gradle.properties` points `android.aapt2FromMavenOverride` at Termux's own aarch64
`aapt2` (2.20, newer than 34.0.4's) and lowers the Gradle/Kotlin daemon heaps to fit the phone.
* `local.properties` in the repo root sets `sdk.dir`; it is git-ignored. The SDK itself is
machine-wide, not project-specific: another project just needs `termux-sdk init <repo>`, and
`termux-sdk install "platforms;android-35" "build-tools;35.0.0"` adds other SDK versions (it
patches their x86_64 native tools to aarch64, which plain `sdkmanager` cannot do). See
`~/CLAUDE.md` for what does and does not build on this phone.
## Releasing (the phone updates through Obtanium)
@@ -77,6 +165,11 @@ after installing one (or build on the desktop and copy the APK over).
## Installing on the phone by hand
> **This is the destructive path: it costs the launcher config unless the backup is restored
> correctly. Do not do it without an explicit reason and a backup.** The current config lives in
> `/data/user/0/de.mm20.launcher2.release` (uid of the release build, currently `u0_a311`); copy it
> out with root before touching anything.
The phone already runs this build. The official build is signed with the upstream key, so it cannot
be updated in place and Obtanium cannot install over it either. The procedure is: back up
`/data/user/0/de.mm20.launcher2.release` with root, `pm uninstall` (a `-k` uninstall keeps the

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

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

@@ -5,26 +5,35 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.icu.text.DateFormat
import android.icu.util.ULocale
import android.provider.AlarmClock
import android.text.format.DateUtils
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.locals.LocalTimeFormat
import de.mm20.launcher2.ui.utils.isTwentyFourHours
import java.util.Date
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.*
/**
* How far ahead the next alarm is shown.
*/
private const val AlarmWindow = 24 * 60 * 60 * 1000L
class AlarmPartProvider : PartProvider {
private val nextAlarmTime = mutableStateOf<Long?>(null)
@@ -43,10 +52,11 @@ class AlarmPartProvider : PartProvider {
send(0)
} else {
time.collectLatest {
if (alarm > it + 8 * 60 * 60 * 1000) {
if (alarm > it + AlarmWindow) {
send(0)
} else {
send(60)
// Highest ranking, so the alarm always sits directly below the clock.
send(100)
}
}
}
@@ -72,11 +82,15 @@ class AlarmPartProvider : PartProvider {
@Composable
override fun Component(compactLayout: Boolean) {
val context = LocalContext.current
val timeFormat = LocalTimeFormat.current
val alarmTime by nextAlarmTime
val time by this.time.collectAsState(System.currentTimeMillis())
alarmTime?.let {
alarmTime?.let { alarm ->
val skeleton = if (timeFormat.isTwentyFourHours(context)) "HH:mm" else "hh:mm a"
val alarmText = DateFormat.getInstanceForSkeleton(skeleton, ULocale.getDefault())
.format(Date(alarm))
if (!compactLayout) {
@@ -94,12 +108,8 @@ class AlarmPartProvider : PartProvider {
)
Text(
modifier = Modifier.padding(start = 12.dp),
text = DateUtils.getRelativeTimeSpanString(
it,
time,
DateUtils.MINUTE_IN_MILLIS
)
.toString(),
text = alarmText,
style = MaterialTheme.typography.titleMedium
)
}
} else {
@@ -118,13 +128,10 @@ class AlarmPartProvider : PartProvider {
)
Text(
modifier = Modifier.padding(start = 12.dp),
text = DateUtils.getRelativeTimeSpanString(
it,
time,
DateUtils.MINUTE_IN_MILLIS
text = alarmText,
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Medium
)
.toString(),
style = MaterialTheme.typography.titleMedium
)
}
}

View File

@@ -0,0 +1,293 @@
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.flow.map
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
/** Re-query interval, so the agenda survives calendar changes that don't notify. */
private const val RefreshInterval = 15 * 60 * 1000L
/** Static ranking: the agenda is shown whenever there are events left today. */
private const val Ranking = 30
/** Width of the dot column. Kept fixed so the rows line up with each other. */
private val DotColumn = 24.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())
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) }
agenda.map { ranking(it) }
.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>): Int {
return if (events.isEmpty()) 0 else Ranking
}
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

@@ -24,7 +24,8 @@ import java.util.*
class DatePartProvider : PartProvider {
override fun getRanking(context: Context): Flow<Int> = flow {
emit(1)
// Second highest, so the date follows the alarm below the clock.
emit(90)
}
@Composable

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

@@ -415,7 +415,7 @@
<string name="preference_enforce_icon_shape_summary">Anvend form på alle ikoner, inklusive dem, der normalt ikke understøtter det</string>
<string name="preference_about_telegram">Telegram-gruppe</string>
<string name="preference_about_license_summary">Licenseret under GNU General Public License 3.0</string>
<string name="preference_clockwidget_alarm_part_summary">Vis alarmer, der ringer inden for de næste 8 timer</string>
<string name="preference_clockwidget_alarm_part_summary">Vis alarmer, der ringer inden for de næste 24 timer</string>
<string name="preference_search_favorites_summary">Vis fastgjorte og ofte brugte elementer over app-gitter</string>
<string name="preference_search_unitconverter_summary">Anvendelse: 1,5 kg eller 4 cm &gt;&gt; in</string>
<string name="preference_favorites_edit_button_summary">Vis en knap for at omarrangere favoritterne</string>

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>
@@ -611,7 +612,14 @@
<string name="preference_clockwidget_battery_part_show">Show when low or charging</string>
<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_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="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)

87
scripts/ondevice.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
#
# Build and test this fork directly on the phone, from Termux.
# See CLAUDE.md ("Building on the phone itself") for the SDK setup this expects.
#
# The debug build is the package "de.mm20.launcher2.debug": a separate install from the
# real launcher ("de.mm20.launcher2.release") with its own, initially empty, data directory.
set -euo pipefail
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
PKG=de.mm20.launcher2.debug
ACTIVITY=de.mm20.launcher2.ui.launcher.LauncherActivity
DEBUG_APK=$REPO/app/app/build/outputs/apk/default/debug/app-default-debug.apk
RELEASE_APK=$REPO/app/app/build/outputs/apk/default/release/app-default-release.apk
usage() {
cat <<'EOF'
Usage: scripts/ondevice.sh <command>
build assemble the debug APK
install build, then install the debug APK (needs root)
start launch the installed debug build
logs follow logcat of the running debug build
crash print the newest crash report for the debug build
release assemble the signed release APK: same package and key as the launcher
installed from Obtanium, so it installs over it and keeps all data
EOF
}
# Termux ships JDK 21 in $PREFIX; on other machines JAVA_HOME has to be set already.
if [ -z "${JAVA_HOME:-}" ] && [ -n "${PREFIX:-}" ] && [ -d "$PREFIX/lib/jvm/java-21-openjdk" ]; then
export JAVA_HOME=$PREFIX/lib/jvm/java-21-openjdk
fi
gradle() {
( cd "$REPO" && ./gradlew --console=plain "$@" )
}
# Long builds outlive the screen lock; keep the CPU awake until they are done.
wake() {
command -v termux-wake-lock >/dev/null && termux-wake-lock || true
}
build_debug() {
wake
gradle :app:app:assembleDefaultDebug
ls -l "$DEBUG_APK"
}
# Every su -c argument below mixes two quoting levels: the single quoted parts are handed
# to the root shell as-is (so $( ) and $pid are expanded there), the "..." parts are
# expanded here.
case "${1:-}" in
build)
build_debug
;;
install)
build_debug
# Termux cannot use pm itself; root can, and can read the APK from ~/sources.
su -c "pm install -r -d '$DEBUG_APK'"
;;
start)
su -c "am start -n $PKG/$ACTIVITY"
;;
logs)
su -c 'pid=$(pidof '"$PKG"')
if [ -z "$pid" ]; then
echo "not running - start it first (scripts/ondevice.sh start)" >&2
exit 1
fi
exec logcat --pid=$pid'
;;
crash)
su -c 'for f in $(ls -t /data/system/dropbox/data_app_crash@*.txt 2>/dev/null | head -30); do
if grep -q '"$PKG"' "$f"; then echo "== $f"; cat "$f"; break; fi
done'
;;
release)
wake
gradle :app:app:assembleDefaultRelease
ls -l "$RELEASE_APK"
;;
*)
usage
exit 1
;;
esac