Compare commits
23 Commits
e17903f40f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c7d3a64c34 | |||
| c824b100b6 | |||
| c56ea58fc5 | |||
| b0072f0fc2 | |||
| 61f3424de5 | |||
| 45ac29e8a7 | |||
| 291752abcd | |||
| 4836618609 | |||
| c8109d9197 | |||
| 3b3d6473ca | |||
| 6aba977823 | |||
| 229e5d1d32 | |||
| cb07acb577 | |||
| a0f9413d40 | |||
| 05c7f0f60d | |||
| 6c6cbe33fa | |||
| 6ae31fe92b | |||
| 3f677e2c87 | |||
| 5c1e9c5bf2 | |||
| e004862b00 | |||
| ff3cab9531 | |||
| 79f229de6d | |||
| 73c3683740 |
127
CLAUDE.md
127
CLAUDE.md
@@ -1,4 +1,4 @@
|
|||||||
# Kvaesitso (fork with type-to-search)
|
# Kvaesitso (fork with type-to-search and a home-screen agenda)
|
||||||
|
|
||||||
Fork of [Kvaesitso](https://github.com/MM2-0/Kvaesitso) that adds type-to-search for phones with a
|
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.
|
physical keyboard. Built for, and installed on, a Unihertz Titan 2 Elite.
|
||||||
@@ -45,9 +45,60 @@ text.
|
|||||||
The search is also cleared whenever the launcher is left, even for a short app switch — upstream
|
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.
|
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 `clockWidgetCalendarPartMaxRows` rows (1..8, default 4) — if
|
||||||
|
there are more, the rows are followed by a "+N more events" hint that opens the calendar app.
|
||||||
|
Tapping a row opens the event
|
||||||
|
(`CalendarEvent.launch`, i.e. `ACTION_VIEW` on `content://com.android.calendar/events/<id>`,
|
||||||
|
which KashCal handles). Rows reuse `CalendarEvent.getSummary()` from
|
||||||
|
`ui/launcher/search/calendar/CalendarItem.kt` (made `internal` for this), so an all-day event
|
||||||
|
reads "All day" and a timed one reads e.g. "09:00 – 09:30".
|
||||||
|
* **The agenda picks its calendars.** `clockWidgetCalendarPartExcludedCalendars` (empty = all)
|
||||||
|
hides individual calendars; the query's exclusion set is that set plus the search settings'
|
||||||
|
exclusions, because the picker only ever offers calendars the search settings leave enabled
|
||||||
|
(`ClockWidgetSettingsScreenVM.calendars` filters them out of `CalendarRepository.getCalendars()`,
|
||||||
|
and a "Manage calendars" button opens `Settings → Search → Calendar` via the
|
||||||
|
`ROUTE_SEARCH_CALENDARS` route in `SettingsActivity`). The whole calendar configuration lives in
|
||||||
|
`ConfigureCalendarPart.kt`, shown by `ConfigureClockWidgetSheet` while the Events part is on —
|
||||||
|
the agenda deliberately ignores the calendar *widget*'s own `excludedCalendarIds`.
|
||||||
|
|
||||||
|
Rankings, in the order the zone prefers them: `CalendarPartProvider` 70 when an event is running or
|
||||||
|
starts within 30 minutes, else 30 while there are events left today, else 0 (so the date part keeps
|
||||||
|
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`, `clockWidgetCalendarPartExcludedCalendars`,
|
||||||
|
`clockWidgetCalendarPartMaxRows`, `clockWidgetDynamicZoneSlots`), `ClockWidgetParts` and the
|
||||||
|
`ClockWidgetSettings` accessors in `core/preferences/.../ui/ClockWidgetSettings.kt` (including the
|
||||||
|
`MinCalendarPartRows`/`MaxCalendarPartRows` bounds), and the UI
|
||||||
|
state in `ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt`. New strings are English-only in
|
||||||
|
`core/i18n/src/main/res/values/strings.xml`; translations come from Crowdin upstream.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
JAVA_HOME=~/jdk21 ANDROID_HOME=~/android-sdk \
|
On the desktop:
|
||||||
|
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-21-openjdk ANDROID_HOME=~/Android/Sdk \
|
||||||
./gradlew :app:app:assembleDefaultRelease
|
./gradlew :app:app:assembleDefaultRelease
|
||||||
|
|
||||||
Result: `app/app/build/outputs/apk/default/release/app-default-release.apk`, applicationId
|
Result: `app/app/build/outputs/apk/default/release/app-default-release.apk`, applicationId
|
||||||
@@ -57,8 +108,71 @@ 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
|
in the repository root (git-ignored) or the `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`, `KEY_ALIAS` and
|
||||||
`KEY_PASSWORD` environment variables. The desktop key is
|
`KEY_PASSWORD` environment variables. The desktop key is
|
||||||
`~/android-keystores/kvaesitso-release.jks`; the phone has its own copy under
|
`~/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
|
`~/android-keystores/` in the Termux home.
|
||||||
after installing one (or build on the desktop and copy the APK over).
|
|
||||||
|
The desktop's checkout (jonas@192.168.1.86) is `~/sources/Kvaesitso`. Its SDK is `~/Android/Sdk`
|
||||||
|
(platforms android-35/37.0, build-tools 34/36/37), so `local.properties` there has
|
||||||
|
`sdk.dir=/home/jonas/Android/Sdk`, and `keystore.properties` points at the copy of the release key
|
||||||
|
in `~/android-keystores/`. It has no `~/jdk21`; it uses Arch's `java-21-openjdk`.
|
||||||
|
|
||||||
|
After the APK is built (on either machine), the release is tagged and published from the phone:
|
||||||
|
|
||||||
|
git tag -a v1.40.2-typing.N -m "Kvaesitso 1.40.2 type-to-search and agenda, Nth build"
|
||||||
|
git push origin main v1.40.2-typing.N
|
||||||
|
|
||||||
|
and a Gitea release for that tag is created with the APK attached as
|
||||||
|
`Kvaesitso-v<versionName>-Signed.apk` (the token is in `~/.config/gitea/token`; the API is
|
||||||
|
`POST /api/v1/repos/jonas/Kvaesitso/releases` and then `.../releases/<id>/assets?name=<asset>` with
|
||||||
|
`-F attachment=@<apk>`).
|
||||||
|
|
||||||
|
### Building on the phone itself (Termux)
|
||||||
|
|
||||||
|
The phone builds this project without a desktop. `scripts/ondevice.sh` wraps the loop:
|
||||||
|
|
||||||
|
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)
|
## Releasing (the phone updates through Obtanium)
|
||||||
|
|
||||||
@@ -77,6 +191,11 @@ after installing one (or build on the desktop and copy the APK over).
|
|||||||
|
|
||||||
## Installing on the phone by hand
|
## 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
|
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
|
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
|
`/data/user/0/de.mm20.launcher2.release` with root, `pm uninstall` (a `-k` uninstall keeps the
|
||||||
|
|||||||
@@ -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() ?: 2026091001
|
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091701
|
||||||
versionName = "1.40.2-typing.2"
|
versionName = "1.40.2-typing.10"
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,10 +1151,27 @@ internal fun LauncherScaffold(
|
|||||||
// The search does not survive leaving the launcher, not even for a short app
|
// The search does not survive leaving the launcher, not even for a short app
|
||||||
// switch: coming back should always show a fresh search.
|
// 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()
|
||||||
@@ -1409,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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,9 +694,29 @@ 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (smartspacer == false) {
|
||||||
|
AnimatedVisibility(visible = parts?.calendar == true) {
|
||||||
|
ConfigureCalendarPart(
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
viewModel = viewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,48 +10,69 @@ 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
|
||||||
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.PartProvider
|
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.PartProvider
|
||||||
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.SmartspacerPartProvider
|
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.SmartspacerPartProvider
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
class ClockWidgetVM : ViewModel(), KoinComponent {
|
class ClockWidgetVM : ViewModel(), KoinComponent {
|
||||||
private val settings: ClockWidgetSettings by inject()
|
private val settings: ClockWidgetSettings by inject()
|
||||||
|
|
||||||
private val partProviders = settings.parts.combine(settings.useSmartspacer) { p, s ->
|
/**
|
||||||
p to s
|
* Kept for the lifetime of the ViewModel instead of being rebuilt whenever the clock widget
|
||||||
}.map { (parts, smartspacer) ->
|
* is subscribed again - which happens on every return to the home screen, because the
|
||||||
if (smartspacer && isAtLeastApiLevel(29)) {
|
* launcher stops while it is in the background. Providers cache the data behind their
|
||||||
return@map listOf(SmartspacerPartProvider())
|
* ranking, so a fresh instance starts at ranking 0 and makes its part disappear until its
|
||||||
|
* query returns; the agenda part would blink on every visit that way.
|
||||||
|
*/
|
||||||
|
private val partProviders = MutableStateFlow<List<PartProvider>>(emptyList())
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settings.parts.combine(settings.useSmartspacer) { p, s -> p to s }
|
||||||
|
.collect { (parts, smartspacer) ->
|
||||||
|
partProviders.value = if (smartspacer && isAtLeastApiLevel(29)) {
|
||||||
|
listOf(SmartspacerPartProvider())
|
||||||
|
} else {
|
||||||
|
buildList {
|
||||||
|
if (parts.date) add(DatePartProvider())
|
||||||
|
if (parts.calendar) add(CalendarPartProvider())
|
||||||
|
if (parts.music) add(MusicPartProvider())
|
||||||
|
add(BatteryPartProvider(parts.battery))
|
||||||
|
if (parts.alarm) add(AlarmPartProvider())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val providers = mutableListOf<PartProvider>()
|
fun getActiveParts(context: Context): Flow<List<PartProvider>> = channelFlow {
|
||||||
if (parts.date) providers += DatePartProvider()
|
|
||||||
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 {
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package de.mm20.launcher2.ui.launcher.widgets.clock
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CheckboxDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.mm20.launcher2.permissions.PermissionGroup
|
||||||
|
import de.mm20.launcher2.permissions.PermissionsManager
|
||||||
|
import de.mm20.launcher2.preferences.ui.MaxCalendarPartRows
|
||||||
|
import de.mm20.launcher2.preferences.ui.MinCalendarPartRows
|
||||||
|
import de.mm20.launcher2.themes.colors.atTone
|
||||||
|
import de.mm20.launcher2.ui.R
|
||||||
|
import de.mm20.launcher2.ui.component.MissingPermissionBanner
|
||||||
|
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
|
||||||
|
import de.mm20.launcher2.ui.component.preferences.Preference
|
||||||
|
import de.mm20.launcher2.ui.component.preferences.SliderPreference
|
||||||
|
import de.mm20.launcher2.ui.locals.LocalDarkTheme
|
||||||
|
import de.mm20.launcher2.ui.settings.SettingsActivity
|
||||||
|
import de.mm20.launcher2.ui.settings.clockwidget.ClockWidgetSettingsScreenVM
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings for the agenda in the clock widget's dynamic zone: how many events are shown
|
||||||
|
* before they collapse into a "+N more events" row, and which calendars they come from.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ConfigureCalendarPart(
|
||||||
|
viewModel: ClockWidgetSettingsScreenVM,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = LocalLifecycleOwner.current as? AppCompatActivity
|
||||||
|
val permissionsManager: PermissionsManager = koinInject()
|
||||||
|
|
||||||
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val excludedCalendars by viewModel.calendarPartExcludedCalendars.collectAsStateWithLifecycle()
|
||||||
|
val maxRows by viewModel.calendarPartMaxRows.collectAsStateWithLifecycle()
|
||||||
|
val hasPermission by viewModel.hasCalendarPermission.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var showCalendars by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val shownCalendars = calendars.count { it.id !in excludedCalendars }
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
if (!hasPermission) {
|
||||||
|
MissingPermissionBanner(
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
text = stringResource(R.string.missing_permission_calendar_widget_settings),
|
||||||
|
onClick = {
|
||||||
|
activity?.let {
|
||||||
|
permissionsManager.requestPermission(it, PermissionGroup.Calendar)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
SliderPreference(
|
||||||
|
title = stringResource(R.string.preference_clockwidget_calendar_part_events),
|
||||||
|
icon = R.drawable.event_24px,
|
||||||
|
value = maxRows,
|
||||||
|
min = MinCalendarPartRows,
|
||||||
|
max = MaxCalendarPartRows,
|
||||||
|
step = 1,
|
||||||
|
onValueChanged = {
|
||||||
|
viewModel.setCalendarPartMaxRows(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
Preference(
|
||||||
|
title = stringResource(R.string.preference_clockwidget_calendar_part_calendars),
|
||||||
|
summary = when {
|
||||||
|
calendars.isEmpty() -> stringResource(R.string.preference_clockwidget_calendar_part_no_calendars)
|
||||||
|
shownCalendars == calendars.size -> stringResource(R.string.preference_clockwidget_calendar_part_all_calendars)
|
||||||
|
else -> stringResource(
|
||||||
|
R.string.preference_clockwidget_calendar_part_calendars_summary,
|
||||||
|
shownCalendars,
|
||||||
|
calendars.size,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = R.drawable.calendar_today_24px,
|
||||||
|
onClick = { showCalendars = !showCalendars },
|
||||||
|
enabled = calendars.isNotEmpty(),
|
||||||
|
controls = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (showCalendars) R.drawable.keyboard_arrow_up_24px
|
||||||
|
else R.drawable.keyboard_arrow_down_24px
|
||||||
|
),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showCalendars && calendars.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
HorizontalDivider()
|
||||||
|
for (calendar in calendars) {
|
||||||
|
CheckboxPreference(
|
||||||
|
title = calendar.name,
|
||||||
|
summary = calendar.owner,
|
||||||
|
iconPadding = false,
|
||||||
|
value = calendar.id !in excludedCalendars,
|
||||||
|
onValueChanged = {
|
||||||
|
viewModel.setCalendarExcluded(calendar.id, !it)
|
||||||
|
},
|
||||||
|
checkboxColors = CheckboxDefaults.colors(
|
||||||
|
checkedColor = if (calendar.color == 0) MaterialTheme.colorScheme.primary
|
||||||
|
else Color(
|
||||||
|
calendar.color.atTone(if (LocalDarkTheme.current) 80 else 40)
|
||||||
|
),
|
||||||
|
checkmarkColor = if (calendar.color == 0) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else Color(
|
||||||
|
calendar.color.atTone(if (LocalDarkTheme.current) 20 else 100)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.align(Alignment.CenterHorizontally),
|
||||||
|
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||||
|
onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(context, SettingsActivity::class.java).apply {
|
||||||
|
putExtra(
|
||||||
|
SettingsActivity.EXTRA_ROUTE,
|
||||||
|
SettingsActivity.ROUTE_SEARCH_CALENDARS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.preference_clockwidget_calendar_part_manage_calendars))
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = ButtonDefaults.IconSpacing)
|
||||||
|
.requiredSize(ButtonDefaults.IconSize),
|
||||||
|
painter = painterResource(R.drawable.open_in_new_20px),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,26 +5,35 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.icu.text.DateFormat
|
||||||
|
import android.icu.util.ULocale
|
||||||
import android.provider.AlarmClock
|
import android.provider.AlarmClock
|
||||||
import android.text.format.DateUtils
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import de.mm20.launcher2.ktx.tryStartActivity
|
import de.mm20.launcher2.ktx.tryStartActivity
|
||||||
import de.mm20.launcher2.ui.R
|
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.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,10 +52,11 @@ 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)
|
// Highest ranking, so the alarm always sits directly below the clock.
|
||||||
|
send(100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,11 +82,15 @@ class AlarmPartProvider : PartProvider {
|
|||||||
@Composable
|
@Composable
|
||||||
override fun Component(compactLayout: Boolean) {
|
override fun Component(compactLayout: Boolean) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val timeFormat = LocalTimeFormat.current
|
||||||
|
|
||||||
val alarmTime by nextAlarmTime
|
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) {
|
if (!compactLayout) {
|
||||||
|
|
||||||
@@ -94,12 +108,8 @@ class AlarmPartProvider : PartProvider {
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
text = DateUtils.getRelativeTimeSpanString(
|
text = alarmText,
|
||||||
it,
|
style = MaterialTheme.typography.titleMedium
|
||||||
time,
|
|
||||||
DateUtils.MINUTE_IN_MILLIS
|
|
||||||
)
|
|
||||||
.toString(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -118,13 +128,10 @@ class AlarmPartProvider : PartProvider {
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
text = DateUtils.getRelativeTimeSpanString(
|
text = alarmText,
|
||||||
it,
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
time,
|
fontWeight = FontWeight.Medium
|
||||||
DateUtils.MINUTE_IN_MILLIS
|
|
||||||
)
|
)
|
||||||
.toString(),
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
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.preferences.ui.ClockWidgetSettings
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of rows the agenda shows before it collapses into a "+N more events" row, until the
|
||||||
|
* setting has been read.
|
||||||
|
*/
|
||||||
|
private const val DefaultMaxRows = 4
|
||||||
|
|
||||||
|
/** Re-query interval, so the agenda survives calendar changes that don't notify. */
|
||||||
|
private const val RefreshInterval = 15 * 60 * 1000L
|
||||||
|
|
||||||
|
/** 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 clockWidgetSettings: ClockWidgetSettings by inject()
|
||||||
|
|
||||||
|
private val agenda = MutableStateFlow<List<CalendarEvent>>(emptyList())
|
||||||
|
|
||||||
|
private val maxRows = MutableStateFlow(DefaultMaxRows)
|
||||||
|
|
||||||
|
override fun getRanking(context: Context): Flow<Int> = channelFlow {
|
||||||
|
// Load the agenda in the background; the ranking itself only depends on the
|
||||||
|
// already known agenda, so it can be emitted right away.
|
||||||
|
launch { observeAgenda(context) }
|
||||||
|
launch { clockWidgetSettings.calendarPartMaxRows.collect { maxRows.value = it } }
|
||||||
|
agenda.map { ranking(it) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { send(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun observeAgenda(context: Context) {
|
||||||
|
combine(
|
||||||
|
dayChanges(context),
|
||||||
|
permissionsManager.hasPermission(PermissionGroup.Calendar),
|
||||||
|
clockWidgetSettings.calendarPartExcludedCalendars,
|
||||||
|
searchSettings.excludedCalendars,
|
||||||
|
) { now, hasPermission, hiddenInWidget, excludedInSearch ->
|
||||||
|
AgendaInputs(
|
||||||
|
now = now,
|
||||||
|
hasCalendarPermission = hasPermission,
|
||||||
|
// Calendars the user disabled in the search settings cannot be picked in the
|
||||||
|
// widget either, so the two sets just add up.
|
||||||
|
excludedCalendars = if (hasPermission) hiddenInWidget + excludedInSearch else emptySet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.collectLatest { (now, hasPermission, excludedCalendars) ->
|
||||||
|
agenda.value = if (!hasPermission) {
|
||||||
|
emptyList()
|
||||||
|
} else withContext(Dispatchers.IO) {
|
||||||
|
calendarRepository.findMany(
|
||||||
|
from = now,
|
||||||
|
to = endOfDay(now),
|
||||||
|
excludeCalendars = excludedCalendars.toList(),
|
||||||
|
// No debounce: the ranking stays 0 until the first result arrives, and
|
||||||
|
// the query is only re-run on a calendar change or every 15 minutes, so
|
||||||
|
// holding the result back would only delay the part's appearance.
|
||||||
|
debounceMillis = 0,
|
||||||
|
).first()
|
||||||
|
.filter { !it.isTask }
|
||||||
|
.sortedBy { it.startTime ?: it.endTime }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 maxRows by this.maxRows.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (events.isEmpty()) return
|
||||||
|
|
||||||
|
val timeFormat = LocalTimeFormat.current
|
||||||
|
val contentColor = LocalContentColor.current
|
||||||
|
|
||||||
|
// The configured number of events, then a "+N more events" row if there are more.
|
||||||
|
val visibleCount = minOf(events.size, maxRows)
|
||||||
|
val hiddenCount = events.size - visibleCount
|
||||||
|
|
||||||
|
Column(
|
||||||
|
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 three inputs the agenda query depends on. */
|
||||||
|
private data class AgendaInputs(
|
||||||
|
val now: Long,
|
||||||
|
val hasCalendarPermission: Boolean,
|
||||||
|
val excludedCalendars: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time of an event, or "all-day" for all-day events. Never a date.
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ import java.util.*
|
|||||||
|
|
||||||
class DatePartProvider : PartProvider {
|
class DatePartProvider : PartProvider {
|
||||||
override fun getRanking(context: Context): Flow<Int> = flow {
|
override fun getRanking(context: Context): Flow<Int> = flow {
|
||||||
emit(1)
|
// Second highest, so the date follows the alarm below the clock.
|
||||||
|
emit(90)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ class SettingsActivity : BaseActivity() {
|
|||||||
ROUTE_MEDIA_INTEGRATION -> MediaIntegrationSettingsRoute
|
ROUTE_MEDIA_INTEGRATION -> MediaIntegrationSettingsRoute
|
||||||
ROUTE_SEARCH_ACTIONS -> SearchActionsSettingsRoute
|
ROUTE_SEARCH_ACTIONS -> SearchActionsSettingsRoute
|
||||||
ROUTE_HIDDEN_ITEMS -> HiddenItemsSettingsRoute
|
ROUTE_HIDDEN_ITEMS -> HiddenItemsSettingsRoute
|
||||||
|
ROUTE_SEARCH_CALENDARS -> CalendarSearchSettingsRoute
|
||||||
ROUTE_CRASH_REPORT if (intent.hasExtra(EXTRA_CRASH_REPORT_PATH)) -> {
|
ROUTE_CRASH_REPORT if (intent.hasExtra(EXTRA_CRASH_REPORT_PATH)) -> {
|
||||||
CrashReportRoute(intent.getStringExtra(EXTRA_CRASH_REPORT_PATH)!!)
|
CrashReportRoute(intent.getStringExtra(EXTRA_CRASH_REPORT_PATH)!!)
|
||||||
}
|
}
|
||||||
@@ -435,6 +436,7 @@ class SettingsActivity : BaseActivity() {
|
|||||||
const val ROUTE_MEDIA_INTEGRATION = "settings/integrations/media"
|
const val ROUTE_MEDIA_INTEGRATION = "settings/integrations/media"
|
||||||
const val ROUTE_SEARCH_ACTIONS = "settings/search/searchactions"
|
const val ROUTE_SEARCH_ACTIONS = "settings/search/searchactions"
|
||||||
const val ROUTE_HIDDEN_ITEMS = "settings/search/hiddenitems"
|
const val ROUTE_HIDDEN_ITEMS = "settings/search/hiddenitems"
|
||||||
|
const val ROUTE_SEARCH_CALENDARS = "settings/search/calendar"
|
||||||
const val ROUTE_CRASH_REPORT = "settings/debug/crashreport"
|
const val ROUTE_CRASH_REPORT = "settings/debug/crashreport"
|
||||||
const val EXTRA_CRASH_REPORT_PATH = "crash_report_path"
|
const val EXTRA_CRASH_REPORT_PATH = "crash_report_path"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ package de.mm20.launcher2.ui.settings.clockwidget
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import de.mm20.launcher2.calendar.CalendarRepository
|
||||||
|
import de.mm20.launcher2.calendar.providers.CalendarList
|
||||||
|
import de.mm20.launcher2.permissions.PermissionGroup
|
||||||
|
import de.mm20.launcher2.permissions.PermissionsManager
|
||||||
import de.mm20.launcher2.preferences.BatteryStatusVisibility
|
import de.mm20.launcher2.preferences.BatteryStatusVisibility
|
||||||
import de.mm20.launcher2.preferences.ClockWidgetAlignment
|
import de.mm20.launcher2.preferences.ClockWidgetAlignment
|
||||||
import de.mm20.launcher2.preferences.ClockWidgetColors
|
import de.mm20.launcher2.preferences.ClockWidgetColors
|
||||||
import de.mm20.launcher2.preferences.ClockWidgetStyle
|
import de.mm20.launcher2.preferences.ClockWidgetStyle
|
||||||
import de.mm20.launcher2.preferences.TimeFormat
|
import de.mm20.launcher2.preferences.TimeFormat
|
||||||
|
import de.mm20.launcher2.preferences.search.CalendarSearchSettings
|
||||||
import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
|
import de.mm20.launcher2.preferences.ui.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
|
||||||
@@ -18,6 +24,9 @@ import org.koin.core.component.inject
|
|||||||
class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
|
class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
|
||||||
private val settings: ClockWidgetSettings by inject()
|
private val settings: ClockWidgetSettings by inject()
|
||||||
private val uiSettings: UiSettings by inject()
|
private val uiSettings: UiSettings by inject()
|
||||||
|
private val calendarRepository: CalendarRepository by inject()
|
||||||
|
private val calendarSearchSettings: CalendarSearchSettings by inject()
|
||||||
|
private val permissionsManager: PermissionsManager by inject()
|
||||||
|
|
||||||
val compact = settings.compact
|
val compact = settings.compact
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||||
@@ -101,6 +110,66 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
|
|||||||
settings.setAlarmPart(alarmPart)
|
settings.setAlarmPart(alarmPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setCalendarPart(calendarPart: Boolean) {
|
||||||
|
settings.setCalendarPart(calendarPart)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of event rows in the agenda before it collapses into a "+N more events" row.
|
||||||
|
*/
|
||||||
|
val calendarPartMaxRows = settings.calendarPartMaxRows
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 4)
|
||||||
|
|
||||||
|
fun setCalendarPartMaxRows(rows: Int) {
|
||||||
|
settings.setCalendarPartMaxRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
val calendarPartExcludedCalendars = settings.calendarPartExcludedCalendars
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptySet<String>())
|
||||||
|
|
||||||
|
fun setCalendarExcluded(calendarId: String, excluded: Boolean) {
|
||||||
|
settings.setCalendarExcluded(calendarId, excluded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars the agenda can show, i.e. every calendar the search settings make available.
|
||||||
|
* Calendars disabled there are missing here on purpose.
|
||||||
|
*/
|
||||||
|
val calendars = combine(
|
||||||
|
calendarRepository.getCalendars(),
|
||||||
|
calendarSearchSettings.excludedCalendars,
|
||||||
|
) { calendars, excluded ->
|
||||||
|
calendars
|
||||||
|
.filter { it.id !in excluded }
|
||||||
|
.sortedWith(compareBy({ it.owner ?: "" }, { it.name }))
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList<CalendarList>())
|
||||||
|
|
||||||
|
/** Whether the agenda can show anything at all. */
|
||||||
|
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), true)
|
||||||
|
|
||||||
|
val slots = settings.slots
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 >> in</string>
|
<string name="preference_search_unitconverter_summary">Anvendelse: 1,5 kg eller 4 cm >> 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>
|
||||||
|
|||||||
@@ -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,20 @@
|
|||||||
<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="preference_clockwidget_calendar_part_events">Visible events</string>
|
||||||
|
<string name="preference_clockwidget_calendar_part_calendars">Calendars</string>
|
||||||
|
<string name="preference_clockwidget_calendar_part_all_calendars">All calendars</string>
|
||||||
|
<string name="preference_clockwidget_calendar_part_calendars_summary">%1$d of %2$d calendars</string>
|
||||||
|
<string name="preference_clockwidget_calendar_part_no_calendars">No calendars found</string>
|
||||||
|
<string name="preference_clockwidget_calendar_part_manage_calendars">Manage calendars</string>
|
||||||
|
<string name="clockwidget_calendar_part_all_day">all-day</string>
|
||||||
|
<plurals name="clockwidget_calendar_part_more_events">
|
||||||
|
<item quantity="one">+%d more event</item>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ 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 clockWidgetCalendarPartExcludedCalendars: Set<String> = emptySet(),
|
||||||
|
val clockWidgetCalendarPartMaxRows: Int = 4,
|
||||||
|
val clockWidgetDynamicZoneSlots: Int = 1,
|
||||||
val clockWidgetFillHeight: Boolean = false,
|
val clockWidgetFillHeight: Boolean = false,
|
||||||
val clockWidgetAlignment: ClockWidgetAlignment = ClockWidgetAlignment.Bottom,
|
val clockWidgetAlignment: ClockWidgetAlignment = ClockWidgetAlignment.Bottom,
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
/** Bounds for the number of event rows the agenda shows before collapsing. */
|
||||||
|
const val MinCalendarPartRows = 1
|
||||||
|
const val MaxCalendarPartRows = 8
|
||||||
|
|
||||||
data class ClockWidgetParts(
|
data class ClockWidgetParts(
|
||||||
val date: Boolean,
|
val date: Boolean,
|
||||||
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 +42,62 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars the agenda does not show. Empty means "all calendars".
|
||||||
|
*/
|
||||||
|
val calendarPartExcludedCalendars
|
||||||
|
get() = launcherDataStore.data.map { it.clockWidgetCalendarPartExcludedCalendars }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
fun setCalendarExcluded(calendarId: String, excluded: Boolean) {
|
||||||
|
launcherDataStore.update {
|
||||||
|
it.copy(
|
||||||
|
clockWidgetCalendarPartExcludedCalendars =
|
||||||
|
if (excluded) it.clockWidgetCalendarPartExcludedCalendars + calendarId
|
||||||
|
else it.clockWidgetCalendarPartExcludedCalendars - calendarId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of event rows in the agenda before it collapses into a "+N more events" row.
|
||||||
|
*/
|
||||||
|
val calendarPartMaxRows
|
||||||
|
get() = launcherDataStore.data
|
||||||
|
.map { it.clockWidgetCalendarPartMaxRows.coerceIn(MinCalendarPartRows, MaxCalendarPartRows) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
fun setCalendarPartMaxRows(rows: Int) {
|
||||||
|
launcherDataStore.update {
|
||||||
|
it.copy(
|
||||||
|
clockWidgetCalendarPartMaxRows = rows.coerceIn(MinCalendarPartRows, MaxCalendarPartRows)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setDatePart(datePart: Boolean) {
|
fun setDatePart(datePart: Boolean) {
|
||||||
launcherDataStore.update {
|
launcherDataStore.update {
|
||||||
it.copy(clockWidgetDatePart = datePart)
|
it.copy(clockWidgetDatePart = datePart)
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ data class WeatherSettingsData(
|
|||||||
val lastLocation: LatLon? = null,
|
val lastLocation: LatLon? = null,
|
||||||
val lastUpdate: Long = 0L,
|
val lastUpdate: Long = 0L,
|
||||||
val providerSettings: Map<String, ProviderSettings> = emptyMap(),
|
val providerSettings: Map<String, ProviderSettings> = emptyMap(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the selected provider resolves its own location (e.g. Breezy Weather, which is
|
||||||
|
* configured with a [WeatherLocation.Managed]). The launcher must then not request a location
|
||||||
|
* for it.
|
||||||
|
*/
|
||||||
|
val managedLocation: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class WeatherSettings internal constructor(
|
class WeatherSettings internal constructor(
|
||||||
@@ -53,6 +60,7 @@ class WeatherSettings internal constructor(
|
|||||||
lastLocation = it.weatherLastLocation,
|
lastLocation = it.weatherLastLocation,
|
||||||
lastUpdate = it.weatherLastUpdate,
|
lastUpdate = it.weatherLastUpdate,
|
||||||
providerSettings = it.weatherProviderSettings,
|
providerSettings = it.weatherProviderSettings,
|
||||||
|
managedLocation = it.weatherProviderSettings[it.weatherProvider]?.managedLocation == true,
|
||||||
)
|
)
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -22,20 +22,28 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.combineTransform
|
import kotlinx.coroutines.flow.combineTransform
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.emitAll
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.transform
|
import kotlinx.coroutines.flow.transform
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.joinAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
import kotlin.time.Duration.Companion.days
|
import kotlin.time.Duration.Companion.days
|
||||||
|
|
||||||
interface CalendarRepository : SearchableRepository<CalendarEvent> {
|
interface CalendarRepository : SearchableRepository<CalendarEvent> {
|
||||||
|
/**
|
||||||
|
* @param debounceMillis how long each result is held back, so bursts of upstream changes
|
||||||
|
* (plugin or permission updates) cause a single query. [debounce] delays the first result
|
||||||
|
* too, so callers that must show something immediately pass 0.
|
||||||
|
*/
|
||||||
fun findMany(
|
fun findMany(
|
||||||
from: Long = System.currentTimeMillis(),
|
from: Long = System.currentTimeMillis(),
|
||||||
to: Long = from + 14 * 24 * 60 * 60 * 1000L,
|
to: Long = from + 14 * 24 * 60 * 60 * 1000L,
|
||||||
excludeCalendars: List<String> = emptyList(),
|
excludeCalendars: List<String> = emptyList(),
|
||||||
excludeAllDayEvents: Boolean = false,
|
excludeAllDayEvents: Boolean = false,
|
||||||
|
debounceMillis: Long = 500,
|
||||||
): Flow<ImmutableList<CalendarEvent>>
|
): Flow<ImmutableList<CalendarEvent>>
|
||||||
|
|
||||||
fun getCalendars(providerId: String? = null): Flow<List<CalendarList>>
|
fun getCalendars(providerId: String? = null): Flow<List<CalendarList>>
|
||||||
@@ -94,6 +102,7 @@ internal class CalendarRepositoryImpl(
|
|||||||
to: Long,
|
to: Long,
|
||||||
excludeCalendars: List<String>,
|
excludeCalendars: List<String>,
|
||||||
excludeAllDayEvents: Boolean,
|
excludeAllDayEvents: Boolean,
|
||||||
|
debounceMillis: Long,
|
||||||
): Flow<ImmutableList<CalendarEvent>> {
|
): Flow<ImmutableList<CalendarEvent>> {
|
||||||
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
|
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
|
||||||
val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks)
|
val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks)
|
||||||
@@ -112,17 +121,17 @@ internal class CalendarRepositoryImpl(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitAll(
|
val events = queryCalendarEvents(
|
||||||
queryCalendarEvents(
|
query = null,
|
||||||
query = null,
|
intervalStart = from,
|
||||||
intervalStart = from,
|
intervalEnd = to,
|
||||||
intervalEnd = to,
|
excludeAllDayEvents = excludeAllDayEvents,
|
||||||
excludeAllDayEvents = excludeAllDayEvents,
|
excludeCalendars = excludeCalendars,
|
||||||
excludeCalendars = excludeCalendars,
|
providers = providers,
|
||||||
providers = providers,
|
awaitAllProviders = true,
|
||||||
allowNetwork = false,
|
allowNetwork = false,
|
||||||
).debounce(500)
|
|
||||||
)
|
)
|
||||||
|
emitAll(if (debounceMillis > 0L) events.debounce(debounceMillis) else events)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +142,17 @@ internal class CalendarRepositoryImpl(
|
|||||||
excludeAllDayEvents: Boolean = false,
|
excludeAllDayEvents: Boolean = false,
|
||||||
excludeCalendars: List<String> = emptyList(),
|
excludeCalendars: List<String> = emptyList(),
|
||||||
allowNetwork: Boolean = false,
|
allowNetwork: Boolean = false,
|
||||||
|
awaitAllProviders: Boolean = false,
|
||||||
providers: List<CalendarProvider>,
|
providers: List<CalendarProvider>,
|
||||||
): Flow<ImmutableList<CalendarEvent>> = flow {
|
): Flow<ImmutableList<CalendarEvent>> = flow {
|
||||||
supervisorScope {
|
supervisorScope {
|
||||||
val result = MutableStateFlow(persistentListOf<CalendarEvent>())
|
// Null until the first provider has reported. Emitting the (empty) accumulator right
|
||||||
|
// away would hand collectors an empty list before any query had run, which callers
|
||||||
|
// that take the first result - like the agenda in the clock widget - cannot tell
|
||||||
|
// apart from "no events".
|
||||||
|
val result = MutableStateFlow<ImmutableList<CalendarEvent>?>(null)
|
||||||
|
|
||||||
for (provider in providers) {
|
val jobs = providers.map { provider ->
|
||||||
launch {
|
launch {
|
||||||
val r = provider.search(
|
val r = provider.search(
|
||||||
query,
|
query,
|
||||||
@@ -152,11 +166,19 @@ internal class CalendarRepositoryImpl(
|
|||||||
allowNetwork = allowNetwork,
|
allowNetwork = allowNetwork,
|
||||||
)
|
)
|
||||||
result.update {
|
result.update {
|
||||||
(it + r).toPersistentList()
|
((it ?: persistentListOf()) + r).toPersistentList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
emitAll(result)
|
|
||||||
|
if (awaitAllProviders) {
|
||||||
|
// One-shot callers must not receive a partial list, so wait until every
|
||||||
|
// provider has reported; if there is none, report an empty result.
|
||||||
|
jobs.joinAll()
|
||||||
|
emit(result.value ?: persistentListOf())
|
||||||
|
} else {
|
||||||
|
emitAll(result.filterNotNull())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ import de.mm20.launcher2.search.SearchableRepository
|
|||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.combineTransform
|
import kotlinx.coroutines.flow.combineTransform
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.emitAll
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.take
|
import kotlinx.coroutines.flow.take
|
||||||
import kotlinx.coroutines.flow.timeout
|
import kotlinx.coroutines.flow.timeout
|
||||||
@@ -32,7 +36,7 @@ internal class LocationsRepository(
|
|||||||
private val permissionsManager: PermissionsManager,
|
private val permissionsManager: PermissionsManager,
|
||||||
) : SearchableRepository<Location> {
|
) : SearchableRepository<Location> {
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
override fun search(
|
override fun search(
|
||||||
query: String,
|
query: String,
|
||||||
allowNetwork: Boolean
|
allowNetwork: Boolean
|
||||||
@@ -40,50 +44,71 @@ internal class LocationsRepository(
|
|||||||
if (query.isBlank() || query.length <= 1) {
|
if (query.isBlank() || query.length <= 1) {
|
||||||
return flowOf(persistentListOf())
|
return flowOf(persistentListOf())
|
||||||
}
|
}
|
||||||
return combineTransform(
|
return combine(
|
||||||
poseProvider
|
|
||||||
.getLocation(minTimeMs = 2000, minDistanceM = 50.0f)
|
|
||||||
// 1st location: lastCachedLocation of poseProvider, if available
|
|
||||||
// 2nd location: LocationManager.getLastKnownLocation(), if available and better than lastCachedLocation
|
|
||||||
// 3rd location: live location from LocationManager.requestLocationUpdates() that is better than any of the previous
|
|
||||||
.take(3)
|
|
||||||
// only request locations for 30 seconds
|
|
||||||
.timeout(30.seconds),
|
|
||||||
permissionsManager.hasPermission(PermissionGroup.Location),
|
permissionsManager.hasPermission(PermissionGroup.Location),
|
||||||
settings.data
|
settings.data
|
||||||
) { userLocation, hasPermission, settingsData ->
|
) { hasPermission, settingsData ->
|
||||||
emit(persistentListOf())
|
!hasPermission || settingsData.providers.isEmpty()
|
||||||
|
}.distinctUntilChanged().flatMapLatest { unusable ->
|
||||||
if (!hasPermission || settingsData.providers.isEmpty()) {
|
if (unusable) {
|
||||||
return@combineTransform
|
// Bail out before building the location flow below. As an argument to
|
||||||
}
|
// combineTransform it is always collected, which registered GPS and network
|
||||||
|
// listeners for every search query even when location search was off or not
|
||||||
val providers = settingsData.providers.map {
|
// permitted.
|
||||||
when (it) {
|
flowOf(persistentListOf())
|
||||||
"openstreetmaps" -> OsmLocationProvider(context, settings)
|
} else {
|
||||||
else -> PluginLocationProvider(context, it)
|
locationSearchFlow(query, allowNetwork)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
supervisorScope {
|
|
||||||
val result = MutableStateFlow(persistentListOf<Location>())
|
|
||||||
|
|
||||||
for (provider in providers) {
|
|
||||||
launch {
|
|
||||||
val r = provider.search(
|
|
||||||
query,
|
|
||||||
userLocation,
|
|
||||||
allowNetwork,
|
|
||||||
settingsData.searchRadius,
|
|
||||||
settingsData.hideUncategorized
|
|
||||||
)
|
|
||||||
result.update {
|
|
||||||
(it + r).toPersistentList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emitAll(result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
private fun locationSearchFlow(
|
||||||
|
query: String,
|
||||||
|
allowNetwork: Boolean,
|
||||||
|
): Flow<ImmutableList<Location>> = combineTransform(
|
||||||
|
poseProvider
|
||||||
|
.getLocation(minTimeMs = 2000, minDistanceM = 50.0f)
|
||||||
|
// 1st location: lastCachedLocation of poseProvider, if available
|
||||||
|
// 2nd location: LocationManager.getLastKnownLocation(), if available and better than lastCachedLocation
|
||||||
|
// 3rd location: live location from LocationManager.requestLocationUpdates() that is better than any of the previous
|
||||||
|
.take(3)
|
||||||
|
// only request locations for 30 seconds
|
||||||
|
.timeout(30.seconds),
|
||||||
|
permissionsManager.hasPermission(PermissionGroup.Location),
|
||||||
|
settings.data
|
||||||
|
) { userLocation, hasPermission, settingsData ->
|
||||||
|
emit(persistentListOf())
|
||||||
|
|
||||||
|
if (!hasPermission || settingsData.providers.isEmpty()) {
|
||||||
|
return@combineTransform
|
||||||
|
}
|
||||||
|
|
||||||
|
val providers = settingsData.providers.map {
|
||||||
|
when (it) {
|
||||||
|
"openstreetmaps" -> OsmLocationProvider(context, settings)
|
||||||
|
else -> PluginLocationProvider(context, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
supervisorScope {
|
||||||
|
val result = MutableStateFlow(persistentListOf<Location>())
|
||||||
|
|
||||||
|
for (provider in providers) {
|
||||||
|
launch {
|
||||||
|
val r = provider.search(
|
||||||
|
query,
|
||||||
|
userLocation,
|
||||||
|
allowNetwork,
|
||||||
|
settingsData.searchRadius,
|
||||||
|
settingsData.hideUncategorized
|
||||||
|
)
|
||||||
|
result.update {
|
||||||
|
(it + r).toPersistentList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitAll(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,14 @@ interface WeatherProvider {
|
|||||||
return 1000 * 60 * 60L
|
return 1000 * 60 * 60L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this provider pushes weather updates on its own instead of being polled, and
|
||||||
|
* resolves its location itself. [WeatherUpdateWorker] then has nothing to pull and must not
|
||||||
|
* request a location for it.
|
||||||
|
*/
|
||||||
|
val isPushBased: Boolean
|
||||||
|
get() = false
|
||||||
|
|
||||||
suspend fun getWeatherData(location: WeatherLocation): List<Forecast>?
|
suspend fun getWeatherData(location: WeatherLocation): List<Forecast>?
|
||||||
suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>?
|
suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>?
|
||||||
suspend fun findLocation(query: String): List<WeatherLocation>
|
suspend fun findLocation(query: String): List<WeatherLocation>
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ internal class WeatherRepositoryImpl(
|
|||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
settings.collectLatest {
|
settings.collectLatest {
|
||||||
val provider = WeatherProvider.getInstance(it.provider)
|
val provider = WeatherProvider.getInstance(it.provider)
|
||||||
|
if (provider.isPushBased) {
|
||||||
|
// The provider sends its updates itself, so there is nothing to poll for.
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork("weather")
|
||||||
|
return@collectLatest
|
||||||
|
}
|
||||||
val weatherRequest =
|
val weatherRequest =
|
||||||
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMillis(provider.getUpdateInterval()))
|
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMillis(provider.getUpdateInterval()))
|
||||||
.build()
|
.build()
|
||||||
@@ -214,6 +219,17 @@ class WeatherUpdateWorker(
|
|||||||
val settingsData = settings.first()
|
val settingsData = settings.first()
|
||||||
val provider = WeatherProvider.getInstance(settingsData.provider)
|
val provider = WeatherProvider.getInstance(settingsData.provider)
|
||||||
|
|
||||||
|
// A push-based provider sends its updates on its own and resolves its own location
|
||||||
|
// (Breezy Weather does both), so there is nothing to pull here. Its getWeatherData()
|
||||||
|
// always returns null, which used to make this worker request a location - registering
|
||||||
|
// GPS and network listeners and holding them for up to ten minutes - and then return
|
||||||
|
// Result.retry() forever, without ever setting lastUpdate, so the interval check below
|
||||||
|
// could never short-circuit it either.
|
||||||
|
if (provider.isPushBased) {
|
||||||
|
Log.d("WeatherUpdateWorker", "Provider pushes updates, nothing to pull")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
val updateInterval = provider.getUpdateInterval()
|
val updateInterval = provider.getUpdateInterval()
|
||||||
val lastUpdate = settingsData.lastUpdate
|
val lastUpdate = settingsData.lastUpdate
|
||||||
|
|
||||||
@@ -222,21 +238,26 @@ class WeatherUpdateWorker(
|
|||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val weatherData = if (settingsData.autoLocation) {
|
val weatherData = when {
|
||||||
val latLon = getLastKnownLocation() ?: settingsData.lastLocation
|
// A managed location means the provider resolves the position itself, so requesting
|
||||||
if (latLon == null) {
|
// one here would be wasted work that holds the location stack open.
|
||||||
Log.e("WeatherUpdateWorker", "Could not get location")
|
settingsData.managedLocation || !settingsData.autoLocation -> {
|
||||||
return Result.failure()
|
val location = settings.location.first()
|
||||||
|
if (location == null) {
|
||||||
|
Log.e("WeatherUpdateWorker", "Location not set")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
provider.getWeatherData(location)
|
||||||
}
|
}
|
||||||
settings.setLastLocation(latLon)
|
else -> {
|
||||||
provider.getWeatherData(latLon.lat, latLon.lon)
|
val latLon = getLastKnownLocation() ?: settingsData.lastLocation
|
||||||
} else {
|
if (latLon == null) {
|
||||||
val location = settings.location.first()
|
Log.e("WeatherUpdateWorker", "Could not get location")
|
||||||
if (location == null) {
|
return Result.failure()
|
||||||
Log.e("WeatherUpdateWorker", "Location not set")
|
}
|
||||||
return Result.failure()
|
settings.setLastLocation(latLon)
|
||||||
|
provider.getWeatherData(latLon.lat, latLon.lon)
|
||||||
}
|
}
|
||||||
provider.getWeatherData(location)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (weatherData == null) {
|
return if (weatherData == null) {
|
||||||
@@ -254,7 +275,10 @@ class WeatherUpdateWorker(
|
|||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
private suspend fun getLastKnownLocation(): LatLon? = locationProvider.getLocation(skipCache = true)
|
private suspend fun getLastKnownLocation(): LatLon? = locationProvider.getLocation(skipCache = true)
|
||||||
.timeout(10.minutes)
|
// Weather does not need a metre-accurate fix; giving up after two minutes instead of ten
|
||||||
|
// keeps the GPS and network listeners (and the wakelocks they hold) short. The caller
|
||||||
|
// falls back to the last known location.
|
||||||
|
.timeout(2.minutes)
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
.or { locationProvider.lastCachedLocation }
|
.or { locationProvider.lastCachedLocation }
|
||||||
?.let { LatLon(it.latitude, it.longitude) }
|
?.let { LatLon(it.latitude, it.longitude) }
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class BreezyWeatherProvider(
|
|||||||
) : WeatherProvider, KoinComponent {
|
) : WeatherProvider, KoinComponent {
|
||||||
private val database: AppDatabase by inject()
|
private val database: AppDatabase by inject()
|
||||||
|
|
||||||
|
// Breezy sends its weather data to us (see pushWeatherData) and manages its own location.
|
||||||
|
override val isPushBased: Boolean = true
|
||||||
|
|
||||||
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
|
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
|
||||||
// Noop implementation, because Breezy weather is handled in a special way
|
// Noop implementation, because Breezy weather is handled in a special way
|
||||||
return null
|
return null
|
||||||
|
|||||||
87
scripts/ondevice.sh
Executable file
87
scripts/ondevice.sh
Executable 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
|
||||||
Reference in New Issue
Block a user