11 Commits

Author SHA1 Message Date
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
5c1e9c5bf2 Handle physical keyboard keys on the scaffold state that is rendered
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Build Nightly APK / build (push) Has been cancelled
The key event handler was installed on the activity together with the state
instance it captured, so it could keep talking to a state that is no longer the
one that is drawn. That happens in practice: the scaffold state is recreated
when one of its rememberSaveable keys changes, which it does right after the
launcher starts (the window is measured, and the state is restored after the
configuration change), and the activity then still holds the handler of the
previous state. Typing on the home screen set the search query and started the
page transition on the detached state, so the search page never appeared, while
the query showed up in the search bar.

The handler is now created without the state as a key and asks for the current
state through a provider, and the activity's handler is refreshed on every
composition instead of only when the handler instance changes.

Verified on the Titan 2: typing on the home screen opens the search page with
the character inserted, further characters append, the text field stays
unfocused and the on-screen keyboard stays hidden.
2026-09-10 21:44:46 +02:00
e004862b00 Bump version to 1.40.2-typing.3
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Fixes on top of -typing.2: the widget configure activity crash, the search bar
keeping the keyboard focus (and the IME) on the home screen after an app switch,
and the TextFieldValue update in SearchBar moving out of the composition.
2026-09-10 20:46:11 +02:00
ff3cab9531 Keep the search bar from holding the keyboard focus on the home screen
Returning to the launcher from another app left the search bar's text field
focused, with the on-screen keyboard up. Typing on the physical keyboard then
went into the search bar - the IME commits characters to the focused text field
- while the search results never appeared, because the key event handler
deliberately leaves keys alone while the text field has the focus. Coming home
looked like the launcher had stopped responding to the keyboard; it happens with
a freshly started launcher and a plain app switch, with no search ever opened.

Give that focus up again one frame after the launcher is resumed, and hide the
on-screen keyboard, as long as the home screen is what is shown. Only on a
resume (pauseTime > 0), so that a search component used as the home screen can
still focus itself when the launcher starts.

Also set isSearchBarFocused back to false when the text field loses the focus,
like upstream does. LauncherSearchBar requests focus whenever that flag is true,
so only ever setting it to true made the text field focus itself again after it
had been unfocused - which is what kept the on-screen keyboard around.

Clear the search query before resetting the page in the resume path, so that a
stale query is gone even if the page transition is interrupted.

Note: the focus fix is a workaround for the framework/IME handing the focus back
to the text field; it is not verified on the device yet.
2026-09-10 20:41:05 +02:00
79f229de6d SearchBar: adopt outside text changes in a side effect
SearchBar keeps a TextFieldValue so that the cursor ends up behind text that was
changed from the outside (a search started by typing on a physical keyboard, or
a reset search). That value was updated during composition, which is an
anti-pattern: the value read earlier in the same composition is what gets drawn,
so the write only schedules another recomposition pass of the search bar - and
it can be dropped entirely if the composition does not complete.

Do the same update in a SideEffect, after the composition. No behaviour change
for the user.
2026-09-10 20:41:01 +02:00
73c3683740 Don't crash when a widget has no configure activity
A widget can advertise WIDGET_FEATURE_RECONFIGURABLE without providing a
configure activity (or the app providing it was uninstalled since). Tapping
"Configure widget" then made
AppWidgetHost.startAppWidgetConfigureActivityForResult throw, which killed the
launcher:

  android.content.ActivityNotFoundException: No Activity found to handle null
    at android.app.Activity.startIntentSenderForResult
    at android.appwidget.AppWidgetHost.startAppWidgetConfigureActivityForResult
    at ConfigureWidgetSheetKt.ConfigureAppWidget$lambda$10$1$0(ConfigureWidgetSheet.kt:812)

This happened three times on the Titan 2 on 2026-09-10 (19:55, 19:57 and 20:03,
the last one 28 seconds after boot). Catch ActivityNotFoundException, and
SecurityException for a configure activity that refuses to be started, and log
instead. Upstream main still has the same unguarded call.
2026-09-10 20:40:57 +02:00
e17903f40f Clear the search when the launcher is left, end keyboard search on dismiss
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Two fixes on top of the type-to-search feature:

- The search no longer survives leaving the launcher. Upstream only resets the
  search when the launcher was in the background for more than five seconds, so
  a quick app switch or a fast return from a launched app left the old query on
  screen. The search page is now always reset, whatever the pause was.
- Dismissing the search page ends the keyboard driven search. Without that the
  flag stayed set, so opening the search later (gesture or search bar) did not
  focus the text field and the soft keyboard stayed away.

Version 1.40.2-typing.2.
2026-09-10 12:03:00 +02:00
75f869c094 Add notes for this fork (type-to-search design, build, release, install) 2026-09-10 11:59:00 +02:00
18 changed files with 783 additions and 48 deletions

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

180
CLAUDE.md Normal file
View File

@@ -0,0 +1,180 @@
# 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.
origin = https://gitea.haugesenspil.dk/jonas/Kvaesitso (our fork — push here)
upstream = https://github.com/MM2-0/Kvaesitso (pull upstream changes from here)
The phone also has this repository checked out in its Termux home: `~/sources/Kvaesitso`.
## The feature
Pressing a printable key on the physical keyboard while the launcher home screen is shown opens the
search page and inserts the character. Backspace deletes, enter launches the highlighted result.
Only the home screen (and the open search page) reacts; the widgets, notifications and recents
pages do not. Setting: *Settings → Search → "Search on typing"* (on by default, stored as
`searchOnTyping`).
## How it works, and why it is built this way
`SharedLauncherActivity.dispatchKeyEvent` is the only place that sees hardware key events while no
view has focus, so the handler is installed there (`launcherKeyEventHandler`, registered by
`LauncherScaffold` while it is shown). The handler itself is
`app/ui/.../ui/launcher/searchbar/TypeToSearchHandler.kt`.
Two constraints shape the design:
1. **The search bar's text field stays unfocused while the keyboard drives the search.** A focused
Compose text field makes the system show the on-screen keyboard. There is no public API to
prevent that: `View.setShowSoftInputOnFocus` is a hidden API (`TextView`'s version is public) and
reflection fails with `NoSuchMethodException`, and hiding the IME reactively makes it flash.
Leaving the field unfocused keeps the IME away completely — which is why the handler inserts and
deletes the characters itself instead of relying on the text field.
2. **No key may be lost.** Characters can arrive before the text field would have focus, so the
handler keeps handling keys until the field reports real focus
(`LauncherScaffoldState.isSearchBarTextFieldFocused`). `LauncherScaffoldState.isTypingSearch`
marks a keyboard-driven search: while it is set, `SearchComponent.onPreActivate` does not focus
the field and the handler owns the keys, including backspace and enter. Tapping the search bar
clears the flag and hands over to the normal editable field.
`SearchBar` keeps a `TextFieldValue` instead of a `String` so that changing the text from the
outside moves the cursor to the end. Without that, the next typed character landed in front of the
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
Result: `app/app/build/outputs/apk/default/release/app-default-release.apk`, applicationId
`de.mm20.launcher2.release`.
The `release` build type signs with the `local` signing config, which reads `keystore.properties`
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.
### 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)
1. Raise `versionCode` above the installed one and set a new `versionName` in
`app/app/build.gradle.kts` (`versionName` may keep the upstream version, e.g. `1.40.2-typing.2`).
2. Build the release APK (see above) and check the signature: `apksigner verify --verbose`.
3. Tag it and push `main` and the tag to `origin` (Gitea needs a token: the desktop keeps it in
`~/.config/gitea/token`).
4. Create a Gitea release for that tag and attach the APK as `Kvaesitso-v<versionName>-Signed.apk`.
The repository is public so Obtanium can read it anonymously.
5. The phone's Obtanium entry for `de.mm20.launcher2.release` points at this Gitea repo with
`overrideSource: "Codeberg"`, `versionDetection: true` and `apkFilterRegEx: "Kvaesitso-v"`. It
picks the release up on the next check. The config file lives on the phone at
`/sdcard/Android/data/dev.imranr.obtainium.fdroid/files/app_data/de.mm20.launcher2.release.json`
(force-stop Obtanium before editing it as root).
## 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
package record *and* its signature, so the next install then fails with
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`), install this APK, then copy the data back — `chown` to the
new app uid and `chcon` the label that installd created for the fresh data directory. The rest of
the phone (bootloader, Magisk, microG, rescue steps) is documented in `~/titan2-elite/CLAUDE.md` on
the desktop.

View File

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

View File

@@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -143,8 +144,14 @@ fun SearchBar(
val textFieldValue = remember { val textFieldValue = remember {
mutableStateOf(TextFieldValue(value, TextRange(value.length))) mutableStateOf(TextFieldValue(value, TextRange(value.length)))
} }
if (textFieldValue.value.text != value) { // Characters the user typed are already in this value; onValueChange below keeps the cursor
textFieldValue.value = TextFieldValue(value, TextRange(value.length)) // where the user put it. Text that changed from the outside - the search was reset, or a
// character was inserted by the physical keyboard handler - is adopted here, with the cursor at
// the end. This must not write state during composition, so it runs in a side effect after it.
SideEffect {
if (textFieldValue.value.text != value) {
textFieldValue.value = TextFieldValue(value, TextRange(value.length))
}
} }
LauncherCard( LauncherCard(

View File

@@ -52,6 +52,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -63,6 +64,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
@@ -79,6 +81,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
@@ -988,6 +991,7 @@ internal fun LauncherScaffold(
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val activity = LocalActivity.current as AppCompatActivity val activity = LocalActivity.current as AppCompatActivity
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current
val wallpaperManager = remember(activity) { WallpaperManager.getInstance(activity) } val wallpaperManager = remember(activity) { WallpaperManager.getInstance(activity) }
@@ -1071,14 +1075,25 @@ internal fun LauncherScaffold(
// Open the search when the user starts typing on a physical keyboard. The handler is // Open the search when the user starts typing on a physical keyboard. The handler is
// installed on the activity, so it also receives key events while no text field is focused. // installed on the activity, so it also receives key events while no text field is focused.
val typeToSearchHandler = rememberTypeToSearchHandler(state, searchVM) //
DisposableEffect(typeToSearchHandler) { // The scaffold state is recreated when one of its keys changes - the window size after a
val launcherActivity = activity as? SharedLauncherActivity // rotation, or the state that rememberSaveable restores after the activity was recreated -
launcherActivity?.launcherKeyEventHandler = typeToSearchHandler // so the handler must not keep a reference to one particular instance: it reads the current
// state through this holder, which the composition keeps up to date, and the activity's
// handler is refreshed on every composition. Without that, the handler can end up opening
// the search on a state that is no longer the one that is rendered, which looks as if the
// key press did nothing.
val currentState = remember { mutableStateOf(state) }
SideEffect {
if (currentState.value !== state) currentState.value = state
}
val typeToSearchHandler = rememberTypeToSearchHandler({ currentState.value }, searchVM)
SideEffect {
(activity as? SharedLauncherActivity)?.launcherKeyEventHandler = typeToSearchHandler
}
DisposableEffect(Unit) {
onDispose { onDispose {
if (launcherActivity?.launcherKeyEventHandler === typeToSearchHandler) { (activity as? SharedLauncherActivity)?.launcherKeyEventHandler = null
launcherActivity.launcherKeyEventHandler = null
}
} }
} }
@@ -1132,12 +1147,31 @@ internal fun LauncherScaffold(
} else { } else {
activity.onBackPressedDispatcher.onBackPressed() activity.onBackPressedDispatcher.onBackPressed()
} }
} else if (activity.pauseTime > 0L && System.currentTimeMillis() - activity.pauseTime > 5000L) { } else if (activity.pauseTime > 0L && (System.currentTimeMillis() - activity.pauseTime > 5000L || state.currentComponent is SearchComponent)) {
// The search does not survive leaving the launcher, not even for a short app
// switch: coming back should always show a fresh search.
if (!state.isLocked) { if (!state.isLocked) {
// Clear the query first: the page transition below animates and must not
// be what keeps a stale query around.
searchVM.reset()
if (state.currentComponent?.survivesPause != true) { if (state.currentComponent?.survivesPause != true) {
state.reset() state.reset()
} }
searchVM.reset() }
}
// The framework hands the keyboard focus - and with it the on-screen keyboard -
// back to the search bar's text field whenever the launcher's window regains
// focus, even though no search is open. The home screen would then sit behind an
// on-screen keyboard and the physical keyboard would type into the search bar
// without the search results ever opening, because the characters are delivered
// to the focused text field instead of the key event handler. Give that focus up
// again, one frame after the resume, so that it also works when the framework
// grants the focus while the window is coming back.
if (activity.pauseTime > 0L) {
withFrameNanos { }
if (state.currentComponent !is SearchComponent) {
focusManager.clearFocus()
activity.hideSoftKeyboard()
} }
} }
awaitCancellation() awaitCancellation()
@@ -1407,8 +1441,11 @@ internal fun LauncherScaffold(
// as usual from now on. // as usual from now on.
state.isTypingSearch = false state.isTypingSearch = false
scope.launch { state.onSearchBarTap() } scope.launch { state.onSearchBarTap() }
state.isSearchBarFocused = true
} }
// Keep this in sync with the real focus state. It must go back to false when
// the text field loses focus, otherwise the launcher keeps asking for it
// (LauncherSearchBar requests focus whenever this is true).
state.isSearchBarFocused = it
}, },
onKeyboardActionGo = if (launchOnEnter) { onKeyboardActionGo = if (launchOnEnter) {
{ searchVM.launchBestMatchOrAction(activity) } { searchVM.launchBestMatchOrAction(activity) }

View File

@@ -115,5 +115,8 @@ internal class SearchComponent(
override fun onPreDismiss(state: LauncherScaffoldState) { override fun onPreDismiss(state: LauncherScaffoldState) {
super.onPreDismiss(state) super.onPreDismiss(state)
state.isSearchBarFocused = false state.isSearchBarFocused = false
// Leaving the search page ends the keyboard driven search, so that opening the search
// again focuses the text field as usual.
state.isTypingSearch = false
} }
} }

View File

@@ -58,6 +58,9 @@ internal fun KeyEvent.isEnter(): Boolean {
* [SharedLauncherActivity.launcherKeyEventHandler]. It is called for every key event before the * [SharedLauncherActivity.launcherKeyEventHandler]. It is called for every key event before the
* event is dispatched to the view hierarchy, so it also works while no text field has focus. * event is dispatched to the view hierarchy, so it also works while no text field has focus.
* *
* The scaffold state the handler works on is passed as a provider rather than as a value, because
* the state is replaced when the window size changes or the launcher is recreated.
*
* While the search is driven by the physical keyboard, the search bar's text field is deliberately * While the search is driven by the physical keyboard, the search bar's text field is deliberately
* left unfocused: a focused text field would make the system show the on-screen keyboard, and the * left unfocused: a focused text field would make the system show the on-screen keyboard, and the
* user is typing on a physical keyboard anyway. The handler therefore inserts and deletes the * user is typing on a physical keyboard anyway. The handler therefore inserts and deletes the
@@ -68,7 +71,7 @@ internal fun KeyEvent.isEnter(): Boolean {
*/ */
@Composable @Composable
internal fun rememberTypeToSearchHandler( internal fun rememberTypeToSearchHandler(
state: LauncherScaffoldState, stateProvider: () -> LauncherScaffoldState,
searchVM: SearchVM, searchVM: SearchVM,
): (KeyEvent) -> Boolean { ): (KeyEvent) -> Boolean {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -76,8 +79,12 @@ internal fun rememberTypeToSearchHandler(
val sheetManager = LocalBottomSheetManager.current val sheetManager = LocalBottomSheetManager.current
val overlayManager = LocalOverlayManager.current val overlayManager = LocalOverlayManager.current
return remember(state, searchVM, activity, sheetManager, overlayManager) { // Deliberately not keyed on the scaffold state: the state is recreated when the window is
// resized or the launcher is recreated, and a key must always be handled by the state that is
// rendered at that moment. stateProvider hands out that state.
return remember(searchVM, activity, sheetManager, overlayManager) {
fun handle(event: KeyEvent): Boolean { fun handle(event: KeyEvent): Boolean {
val state = stateProvider()
// Read the preference here rather than capturing it in the composition, so that the // Read the preference here rather than capturing it in the composition, so that the
// current value is always used. // current value is always used.
if (!searchVM.searchOnTyping.value || state.isLocked) return false if (!searchVM.searchOnTyping.value || state.isLocked) return false

View File

@@ -4,9 +4,11 @@ import android.app.Activity
import android.app.ActivityOptions import android.app.ActivityOptions
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo import android.appwidget.AppWidgetProviderInfo
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@@ -809,21 +811,32 @@ fun ColumnScope.ConfigureAppWidget(
bottom = 8.dp, bottom = 8.dp,
), ),
onClick = { onClick = {
appWidgetHost.startAppWidgetConfigureActivityForResult( try {
lifecycleOwner as Activity, appWidgetHost.startAppWidgetConfigureActivityForResult(
widget.config.widgetId, lifecycleOwner as Activity,
0, widget.config.widgetId,
0, 0,
if (Build.VERSION.SDK_INT < 34) { 0,
null if (Build.VERSION.SDK_INT < 34) {
} else { null
ActivityOptions.makeBasic() } else {
.setPendingIntentBackgroundActivityStartMode( ActivityOptions.makeBasic()
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED .setPendingIntentBackgroundActivityStartMode(
) ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
.toBundle() )
} .toBundle()
) }
)
} catch (e: ActivityNotFoundException) {
// A widget can advertise WIDGET_FEATURE_RECONFIGURABLE without providing a
// configure activity (or the app providing it was uninstalled). Starting it
// then throws, which would crash the launcher.
Log.w("MM20", "No configure activity for widget ${widget.config.widgetId}", e)
} catch (e: SecurityException) {
// The configure activity refused to be started, e.g. because the app owning
// the widget is not allowed to start activities from the background.
Log.w("MM20", "Widget configure activity refused to start", e)
}
}) { }) {
Text( Text(
stringResource(id = R.string.widget_config_appwidget_configure) stringResource(id = R.string.widget_config_appwidget_configure)

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.Preference
import de.mm20.launcher2.ui.component.preferences.SwitchPreference import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.component.preferences.ListPreference 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.AnalogClock
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.BinaryClock import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.BinaryClock
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.CustomClock import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.CustomClock
@@ -105,8 +106,8 @@ fun ClockWidget(
viewModel.updateTime(time) viewModel.updateTime(time)
} }
val partProvider by remember { viewModel.getActivePart(context) }.collectAsStateWithLifecycle( val partProviders by remember { viewModel.getActiveParts(context) }.collectAsStateWithLifecycle(
null emptyList()
) )
AnimatedContent(editMode, label = "ClockWidget") { AnimatedContent(editMode, label = "ClockWidget") {
@@ -181,11 +182,11 @@ fun ClockWidget(
Clock(clockStyle, false, darkColors) Clock(clockStyle, false, darkColors)
} }
if (partProvider != null) { partProviders.forEach { provider ->
DynamicZone( DynamicZone(
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
compact = false, compact = false,
provider = partProvider, provider = provider,
) )
} }
} }
@@ -198,12 +199,13 @@ fun ClockWidget(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
if (partProvider != null) { Column(modifier = Modifier.weight(1f)) {
DynamicZone( partProviders.forEach { provider ->
modifier = Modifier.weight(1f), DynamicZone(
compact = true, compact = true,
provider = partProvider, provider = provider,
) )
}
} }
if (clockStyle !is ClockWidgetStyle.Empty) { if (clockStyle !is ClockWidgetStyle.Empty) {
Box( Box(
@@ -362,6 +364,8 @@ fun ConfigureClockWidgetSheet(
val useAccentColor by viewModel.useThemeColor.collectAsState() val useAccentColor by viewModel.useThemeColor.collectAsState()
val parts by viewModel.parts.collectAsState() val parts by viewModel.parts.collectAsState()
val smartspacer by viewModel.useSmartspacer.collectAsState() val smartspacer by viewModel.useSmartspacer.collectAsState()
val slots by viewModel.slots.collectAsState()
val partCount by viewModel.partCount.collectAsState()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -668,6 +672,15 @@ fun ConfigureClockWidgetSheet(
viewModel.setAlarmPart(it) 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( ListPreference(
title = stringResource(R.string.preference_clockwidget_battery_part), title = stringResource(R.string.preference_clockwidget_battery_part),
icon = R.drawable.battery_full_24px, icon = R.drawable.battery_full_24px,
@@ -681,6 +694,18 @@ fun ConfigureClockWidgetSheet(
stringResource(R.string.preference_clockwidget_battery_part_always_show) to BatteryStatusVisibility.Always 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.preferences.ui.ClockWidgetSettings
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.AlarmPartProvider 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.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.DatePartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.FavoritesPartProvider import de.mm20.launcher2.ui.launcher.widgets.clock.parts.FavoritesPartProvider
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.MusicPartProvider import de.mm20.launcher2.ui.launcher.widgets.clock.parts.MusicPartProvider
@@ -37,21 +38,27 @@ class ClockWidgetVM : ViewModel(), KoinComponent {
val providers = mutableListOf<PartProvider>() val providers = mutableListOf<PartProvider>()
if (parts.date) providers += DatePartProvider() if (parts.date) providers += DatePartProvider()
if (parts.calendar) providers += CalendarPartProvider()
if (parts.music) providers += MusicPartProvider() if (parts.music) providers += MusicPartProvider()
providers += BatteryPartProvider(parts.battery) providers += BatteryPartProvider(parts.battery)
if (parts.alarm) providers += AlarmPartProvider() if (parts.alarm) providers += AlarmPartProvider()
providers providers
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
fun getActivePart(context: Context): Flow<PartProvider?> = channelFlow { fun getActiveParts(context: Context): Flow<List<PartProvider>> = channelFlow {
partProviders.collectLatest { providers -> partProviders.collectLatest { providers ->
if (providers.isEmpty()) { if (providers.isEmpty()) {
send(null) send(emptyList())
return@collectLatest return@collectLatest
} }
val rankings = providers.map { it.getRanking(context).map { r -> r to it } } val rankings = providers.map { it.getRanking(context).map { r -> r to it } }
combine(rankings) { r -> val ranked = combine(rankings) { r ->
r.filter { it.first > 0 }.maxByOrNull { it.first }?.second r.filter { it.first > 0 }
.sortedByDescending { it.first }
.map { it.second }
}
combine(ranked, settings.slots) { r, slots ->
r.take(slots.coerceAtLeast(1))
}.collectLatest { }.collectLatest {
send(it) send(it)
} }

View File

@@ -25,6 +25,11 @@ import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
/**
* How far ahead the next alarm is shown.
*/
private const val AlarmWindow = 24 * 60 * 60 * 1000L
class AlarmPartProvider : PartProvider { class AlarmPartProvider : PartProvider {
private val nextAlarmTime = mutableStateOf<Long?>(null) private val nextAlarmTime = mutableStateOf<Long?>(null)
@@ -43,7 +48,7 @@ class AlarmPartProvider : PartProvider {
send(0) send(0)
} else { } else {
time.collectLatest { time.collectLatest {
if (alarm > it + 8 * 60 * 60 * 1000) { if (alarm > it + AlarmWindow) {
send(0) send(0)
} else { } else {
send(60) send(60)

View File

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

View File

@@ -11,6 +11,7 @@ import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
import de.mm20.launcher2.preferences.ui.UiSettings import de.mm20.launcher2.preferences.ui.UiSettings
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@@ -101,6 +102,32 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
settings.setAlarmPart(alarmPart) 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 val alignment = settings.alignment
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) .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_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_telegram">Telegram-gruppe</string>
<string name="preference_about_license_summary">Licenseret under GNU General Public License 3.0</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_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_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> <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_center">Center</string>
<string name="preference_clock_widget_alignment_bottom">Bottom</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">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_smartspacer">Managed by Smartspacer</string>
<string name="preference_clockwidget_date_part">Date</string> <string name="preference_clockwidget_date_part">Date</string>
<string name="preference_clockwidget_date_part_summary">Show the current 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_show">Show when low or charging</string>
<string name="preference_clockwidget_battery_part_hide">Off</string> <string name="preference_clockwidget_battery_part_hide">Off</string>
<string name="preference_clockwidget_alarm_part">Alarms</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">Backup and restore</string>
<string name="preference_screen_backup_summary">Export and import launcher data</string> <string name="preference_screen_backup_summary">Export and import launcher data</string>
<string name="preference_backup">Backup</string> <string name="preference_backup">Backup</string>

View File

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

View File

@@ -16,6 +16,7 @@ data class ClockWidgetParts(
val music: Boolean = false, val music: Boolean = false,
val battery: BatteryStatusVisibility = BatteryStatusVisibility.Hide, val battery: BatteryStatusVisibility = BatteryStatusVisibility.Hide,
val alarm: Boolean = false, val alarm: Boolean = false,
val calendar: Boolean = false,
) )
class ClockWidgetSettings internal constructor( class ClockWidgetSettings internal constructor(
@@ -37,9 +38,29 @@ class ClockWidgetSettings internal constructor(
music = it.clockWidgetMusicPart, music = it.clockWidgetMusicPart,
battery = it.clockWidgetBatteryPart, battery = it.clockWidgetBatteryPart,
alarm = it.clockWidgetAlarmPart, alarm = it.clockWidgetAlarmPart,
calendar = it.clockWidgetCalendarPart,
) )
}.distinctUntilChanged() }.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) { fun setDatePart(datePart: Boolean) {
launcherDataStore.update { launcherDataStore.update {
it.copy(clockWidgetDatePart = datePart) 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