Compare commits
4 Commits
v1.40.2-ty
...
05c7f0f60d
| Author | SHA1 | Date | |
|---|---|---|---|
| 05c7f0f60d | |||
| 6c6cbe33fa | |||
| 6ae31fe92b | |||
| 3f677e2c87 |
99
CLAUDE.md
99
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
|
||||
physical keyboard. Built for, and installed on, a Unihertz Titan 2 Elite.
|
||||
@@ -45,8 +45,48 @@ text.
|
||||
The search is also cleared whenever the launcher is left, even for a short app switch — upstream
|
||||
keeps it for returns within five seconds, which leaves a stale query on screen.
|
||||
|
||||
## The clock widget's dynamic zone (agenda part and multiple slots)
|
||||
|
||||
Upstream's home-screen clock has one "dynamic zone" below (or beside) the clock. Every enabled
|
||||
*part* (`DatePartProvider`, `MusicPartProvider`, `BatteryPartProvider`, `AlarmPartProvider`) reports
|
||||
a `getRanking()`; only the single highest-ranked part with a ranking above zero is shown. Two
|
||||
changes on top of that:
|
||||
|
||||
* **The zone can show more than one part.** The setting `clockWidgetDynamicZoneSlots` (1..number of
|
||||
enabled parts) caps how many of the highest-ranked parts are rendered;
|
||||
`ClockWidgetVM.getActiveParts()` returns that sorted list instead of a single provider. The
|
||||
slider lives in `ConfigureClockWidgetSheet` next to the part toggles.
|
||||
* **A new part shows today's agenda.** `CalendarPartProvider`
|
||||
(`app/ui/.../widgets/clock/parts/`) is toggled with the *Events* switch
|
||||
(`clockWidgetCalendarPart`). It lists the events that are still running or upcoming today, plus
|
||||
today's all-day events, capped at 4 rows — if there are more, the last row is a "+N more events"
|
||||
hint that opens the calendar app. Tapping a row opens the event
|
||||
(`CalendarEvent.launch`, i.e. `ACTION_VIEW` on `content://com.android.calendar/events/<id>`,
|
||||
which KashCal handles). Rows reuse `CalendarEvent.getSummary()` from
|
||||
`ui/launcher/search/calendar/CalendarItem.kt` (made `internal` for this), so an all-day event
|
||||
reads "All day" and a timed one reads e.g. "09:00 – 09:30".
|
||||
|
||||
Rankings, in the order the zone prefers them: `CalendarPartProvider` 70 when an event is running or
|
||||
starts within 30 minutes, else 30 while there are events left today, else 0 (so the date part keeps
|
||||
the slot on an empty day); alarms 60 (ringing within 8 h); media 80 while playing; battery 55 when
|
||||
low and 10 when charging or set to "always"; date 1. Tasks (`CalendarEvent.isTask`) are filtered out
|
||||
of the agenda; a tasks part is planned but not implemented.
|
||||
|
||||
`CalendarPartProvider` keeps itself up to date with a `ContentObserver` on
|
||||
`CalendarContract.Events.CONTENT_URI` plus a 15-minute ticker (the other calendar consumers in the
|
||||
launcher only re-query when they become visible). `PartProvider.setTime()` is called every second
|
||||
by the clock widget; the provider folds that into its ranking instead of re-querying.
|
||||
|
||||
The clock settings themselves are in `core/preferences`: `LauncherSettingsData` (new fields
|
||||
`clockWidgetCalendarPart`, `clockWidgetDynamicZoneSlots`), `ClockWidgetParts` and the
|
||||
`ClockWidgetSettings` accessors in `core/preferences/.../ui/ClockWidgetSettings.kt`, and the UI
|
||||
state in `ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt`. New strings are English-only in
|
||||
`core/i18n/src/main/res/values/strings.xml`; translations come from Crowdin upstream.
|
||||
|
||||
## Building
|
||||
|
||||
On the desktop:
|
||||
|
||||
JAVA_HOME=~/jdk21 ANDROID_HOME=~/android-sdk \
|
||||
./gradlew :app:app:assembleDefaultRelease
|
||||
|
||||
@@ -57,8 +97,56 @@ The `release` build type signs with the `local` signing config, which reads `key
|
||||
in the repository root (git-ignored) or the `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`, `KEY_ALIAS` and
|
||||
`KEY_PASSWORD` environment variables. The desktop key is
|
||||
`~/android-keystores/kvaesitso-release.jks`; the phone has its own copy under
|
||||
`~/android-keystores/` in the Termux home. Termux itself has no Android SDK, so build there only
|
||||
after installing one (or build on the desktop and copy the APK over).
|
||||
`~/android-keystores/` in the Termux home.
|
||||
|
||||
### Building on the phone itself (Termux)
|
||||
|
||||
The phone builds this project without a desktop. `scripts/ondevice.sh` wraps the loop:
|
||||
|
||||
scripts/ondevice.sh build # assemble the debug APK
|
||||
scripts/ondevice.sh install # build, then pm install it (root)
|
||||
scripts/ondevice.sh start # launch the debug build
|
||||
scripts/ondevice.sh logs # follow logcat of the debug build (Ctrl-C to stop)
|
||||
scripts/ondevice.sh crash # newest crash report for the debug build
|
||||
scripts/ondevice.sh release # assemble the signed release APK
|
||||
|
||||
Or call Gradle directly: `./gradlew :app:app:assembleDefaultDebug`. A full build takes about 13
|
||||
minutes (984 tasks), later ones 20-30 seconds; the release build (R8 plus `lintVital`) takes about
|
||||
11 minutes. Run `termux-wake-lock` first so the screen lock does not throttle it.
|
||||
|
||||
The **debug** build is `de.mm20.launcher2.debug` (`isDebuggable`, its own empty data directory),
|
||||
so it installs next to the real launcher and can be logged with
|
||||
`su -c 'logcat --pid=$(pidof de.mm20.launcher2.debug)'`. Installing, reinstalling or uninstalling
|
||||
that package cannot affect the real launcher: different package, different data directory.
|
||||
|
||||
> **Never uninstall `de.mm20.launcher2.release`.** It is the launcher the phone actually runs, and
|
||||
> the config (widgets, pages, tags, search settings) lives in its data directory
|
||||
> `/data/user/0/de.mm20.launcher2.release`, mostly under `files/datastore`. `pm install -r` of a
|
||||
> build signed with the same key keeps that data, but it does replace the running launcher, so do it
|
||||
> deliberately, after backing the data directory up. Reinstalling the *debug* build is always the
|
||||
> safe way to try a change.
|
||||
|
||||
To try a fix in the real launcher with the real data, build the release APK and `pm install -r` it:
|
||||
same package (`de.mm20.launcher2.release`) and same key, so it replaces the installed build in
|
||||
place. The release APK is minified by R8, so this is the slow build. `scripts/ondevice.sh release`
|
||||
only *builds* it; it never installs anything.
|
||||
|
||||
#### The SDK on the phone
|
||||
|
||||
`~/android-sdk` (see `~/CLAUDE.md`) provides everything Gradle needs:
|
||||
|
||||
* `platforms/android-37.0` — the compileSdk 37 platform, installed with `sdkmanager`.
|
||||
* `build-tools/36.0.0` — the version AGP 9.3.1 defaults to. Google only ships x86_64 build-tools,
|
||||
so the three native tools are symlinks into `build-tools/34.0.4`, which is AndroidIDE's aarch64
|
||||
build of the AOSP tools: `aapt2`, `zipalign` and `aidl`. Everything else in build-tools
|
||||
(`d8`, `apksigner`, jars) is Java and runs as is.
|
||||
* `~/.gradle/gradle.properties` points `android.aapt2FromMavenOverride` at Termux's own aarch64
|
||||
`aapt2` (2.20, newer than 34.0.4's) and lowers the Gradle/Kotlin daemon heaps to fit the phone.
|
||||
* `local.properties` in the repo root sets `sdk.dir`; it is git-ignored. The SDK itself is
|
||||
machine-wide, not project-specific: another project just needs `termux-sdk init <repo>`, and
|
||||
`termux-sdk install "platforms;android-35" "build-tools;35.0.0"` adds other SDK versions (it
|
||||
patches their x86_64 native tools to aarch64, which plain `sdkmanager` cannot do). See
|
||||
`~/CLAUDE.md` for what does and does not build on this phone.
|
||||
|
||||
## Releasing (the phone updates through Obtanium)
|
||||
|
||||
@@ -77,6 +165,11 @@ after installing one (or build on the desktop and copy the APK over).
|
||||
|
||||
## Installing on the phone by hand
|
||||
|
||||
> **This is the destructive path: it costs the launcher config unless the backup is restored
|
||||
> correctly. Do not do it without an explicit reason and a backup.** The current config lives in
|
||||
> `/data/user/0/de.mm20.launcher2.release` (uid of the release build, currently `u0_a311`); copy it
|
||||
> out with root before touching anything.
|
||||
|
||||
The phone already runs this build. The official build is signed with the upstream key, so it cannot
|
||||
be updated in place and Obtanium cannot install over it either. The procedure is: back up
|
||||
`/data/user/0/de.mm20.launcher2.release` with root, `pm uninstall` (a `-k` uninstall keeps the
|
||||
|
||||
@@ -34,8 +34,8 @@ android {
|
||||
applicationId = "de.mm20.launcher2"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091003
|
||||
versionName = "1.40.2-typing.4"
|
||||
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091004
|
||||
versionName = "1.40.2-typing.5"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import de.mm20.launcher2.ui.component.DismissableBottomSheet
|
||||
import de.mm20.launcher2.ui.component.preferences.Preference
|
||||
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
|
||||
import de.mm20.launcher2.ui.component.preferences.ListPreference
|
||||
import de.mm20.launcher2.ui.component.preferences.SliderPreference
|
||||
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.AnalogClock
|
||||
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.BinaryClock
|
||||
import de.mm20.launcher2.ui.launcher.widgets.clock.clocks.CustomClock
|
||||
@@ -105,8 +106,8 @@ fun ClockWidget(
|
||||
viewModel.updateTime(time)
|
||||
}
|
||||
|
||||
val partProvider by remember { viewModel.getActivePart(context) }.collectAsStateWithLifecycle(
|
||||
null
|
||||
val partProviders by remember { viewModel.getActiveParts(context) }.collectAsStateWithLifecycle(
|
||||
emptyList()
|
||||
)
|
||||
|
||||
AnimatedContent(editMode, label = "ClockWidget") {
|
||||
@@ -181,11 +182,11 @@ fun ClockWidget(
|
||||
Clock(clockStyle, false, darkColors)
|
||||
}
|
||||
|
||||
if (partProvider != null) {
|
||||
partProviders.forEach { provider ->
|
||||
DynamicZone(
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
compact = false,
|
||||
provider = partProvider,
|
||||
provider = provider,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -198,12 +199,13 @@ fun ClockWidget(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (partProvider != null) {
|
||||
DynamicZone(
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true,
|
||||
provider = partProvider,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
partProviders.forEach { provider ->
|
||||
DynamicZone(
|
||||
compact = true,
|
||||
provider = provider,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (clockStyle !is ClockWidgetStyle.Empty) {
|
||||
Box(
|
||||
@@ -362,6 +364,8 @@ fun ConfigureClockWidgetSheet(
|
||||
val useAccentColor by viewModel.useThemeColor.collectAsState()
|
||||
val parts by viewModel.parts.collectAsState()
|
||||
val smartspacer by viewModel.useSmartspacer.collectAsState()
|
||||
val slots by viewModel.slots.collectAsState()
|
||||
val partCount by viewModel.partCount.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -668,6 +672,15 @@ fun ConfigureClockWidgetSheet(
|
||||
viewModel.setAlarmPart(it)
|
||||
}
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(R.string.preference_clockwidget_calendar_part),
|
||||
summary = stringResource(R.string.preference_clockwidget_calendar_part_summary),
|
||||
icon = R.drawable.event_24px,
|
||||
value = parts?.calendar == true,
|
||||
onValueChanged = {
|
||||
viewModel.setCalendarPart(it)
|
||||
}
|
||||
)
|
||||
ListPreference(
|
||||
title = stringResource(R.string.preference_clockwidget_battery_part),
|
||||
icon = R.drawable.battery_full_24px,
|
||||
@@ -681,6 +694,18 @@ fun ConfigureClockWidgetSheet(
|
||||
stringResource(R.string.preference_clockwidget_battery_part_always_show) to BatteryStatusVisibility.Always
|
||||
)
|
||||
)
|
||||
SliderPreference(
|
||||
title = stringResource(R.string.preference_clockwidget_dynamic_zone_slots),
|
||||
icon = R.drawable.dashboard_2_24px,
|
||||
value = slots,
|
||||
min = 1,
|
||||
max = maxOf(1, partCount),
|
||||
step = 1,
|
||||
enabled = partCount > 1,
|
||||
onValueChanged = {
|
||||
viewModel.setSlots(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
|
||||
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.AlarmPartProvider
|
||||
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.BatteryPartProvider
|
||||
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.CalendarPartProvider
|
||||
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.DatePartProvider
|
||||
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.FavoritesPartProvider
|
||||
import de.mm20.launcher2.ui.launcher.widgets.clock.parts.MusicPartProvider
|
||||
@@ -37,21 +38,27 @@ class ClockWidgetVM : ViewModel(), KoinComponent {
|
||||
|
||||
val providers = mutableListOf<PartProvider>()
|
||||
if (parts.date) providers += DatePartProvider()
|
||||
if (parts.calendar) providers += CalendarPartProvider()
|
||||
if (parts.music) providers += MusicPartProvider()
|
||||
providers += BatteryPartProvider(parts.battery)
|
||||
if (parts.alarm) providers += AlarmPartProvider()
|
||||
providers
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
|
||||
|
||||
fun getActivePart(context: Context): Flow<PartProvider?> = channelFlow {
|
||||
fun getActiveParts(context: Context): Flow<List<PartProvider>> = channelFlow {
|
||||
partProviders.collectLatest { providers ->
|
||||
if (providers.isEmpty()) {
|
||||
send(null)
|
||||
send(emptyList())
|
||||
return@collectLatest
|
||||
}
|
||||
val rankings = providers.map { it.getRanking(context).map { r -> r to it } }
|
||||
combine(rankings) { r ->
|
||||
r.filter { it.first > 0 }.maxByOrNull { it.first }?.second
|
||||
val ranked = combine(rankings) { r ->
|
||||
r.filter { it.first > 0 }
|
||||
.sortedByDescending { it.first }
|
||||
.map { it.second }
|
||||
}
|
||||
combine(ranked, settings.slots) { r, slots ->
|
||||
r.take(slots.coerceAtLeast(1))
|
||||
}.collectLatest {
|
||||
send(it)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
/**
|
||||
* How far ahead the next alarm is shown.
|
||||
*/
|
||||
private const val AlarmWindow = 24 * 60 * 60 * 1000L
|
||||
|
||||
class AlarmPartProvider : PartProvider {
|
||||
|
||||
private val nextAlarmTime = mutableStateOf<Long?>(null)
|
||||
@@ -43,7 +48,7 @@ class AlarmPartProvider : PartProvider {
|
||||
send(0)
|
||||
} else {
|
||||
time.collectLatest {
|
||||
if (alarm > it + 8 * 60 * 60 * 1000) {
|
||||
if (alarm > it + AlarmWindow) {
|
||||
send(0)
|
||||
} else {
|
||||
send(60)
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package de.mm20.launcher2.ui.launcher.widgets.clock.parts
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.ContentObserver
|
||||
import android.icu.text.DateFormat
|
||||
import android.icu.util.ULocale
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.mm20.launcher2.calendar.CalendarRepository
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.permissions.PermissionGroup
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.preferences.TimeFormat
|
||||
import de.mm20.launcher2.preferences.search.CalendarSearchSettings
|
||||
import de.mm20.launcher2.search.CalendarEvent
|
||||
import de.mm20.launcher2.ui.R
|
||||
import de.mm20.launcher2.ui.locals.LocalTimeFormat
|
||||
import de.mm20.launcher2.ui.utils.isTwentyFourHours
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Highest number of rows the agenda shows. If there are more events than this, the last
|
||||
* row is a "+N more events" hint.
|
||||
*/
|
||||
private const val MaxRows = 4
|
||||
|
||||
/** An event that starts within this time frame (or is running) raises the ranking. */
|
||||
private const val SoonThreshold = 30 * 60 * 1000L
|
||||
|
||||
/** Re-query interval, so the agenda survives calendar changes that don't notify. */
|
||||
private const val RefreshInterval = 15 * 60 * 1000L
|
||||
|
||||
private const val RankingToday = 30
|
||||
private const val RankingSoon = 70
|
||||
|
||||
/** Width of the dot column. Kept fixed so the rows line up with each other. */
|
||||
private val DotColumn = 12.dp
|
||||
|
||||
/** Width of the time column. Wide enough for "11:59 PM" and "all-day". */
|
||||
private val TimeColumn = 64.dp
|
||||
|
||||
/** Width of the title column. Titles longer than this are ellipsized. */
|
||||
private val TitleColumn = 176.dp
|
||||
|
||||
/**
|
||||
* Dynamic zone part that shows today's agenda.
|
||||
*
|
||||
* Only events that are still running or upcoming today are shown, plus all-day events of
|
||||
* today. Tasks are ignored for now.
|
||||
*/
|
||||
class CalendarPartProvider : PartProvider, KoinComponent {
|
||||
|
||||
private val calendarRepository: CalendarRepository by inject()
|
||||
private val permissionsManager: PermissionsManager by inject()
|
||||
private val searchSettings: CalendarSearchSettings by inject()
|
||||
|
||||
private val agenda = MutableStateFlow<List<CalendarEvent>>(emptyList())
|
||||
private val time = MutableStateFlow(System.currentTimeMillis())
|
||||
|
||||
override fun setTime(time: Long) {
|
||||
this.time.value = time
|
||||
}
|
||||
|
||||
override fun getRanking(context: Context): Flow<Int> = channelFlow {
|
||||
// Load the agenda in the background; the ranking itself only depends on the
|
||||
// already known agenda and the current time, so it can be emitted right away.
|
||||
launch { observeAgenda(context) }
|
||||
combine(agenda, time) { events, now -> ranking(events, now) }
|
||||
.distinctUntilChanged()
|
||||
.collect { send(it) }
|
||||
}
|
||||
|
||||
private suspend fun observeAgenda(context: Context) {
|
||||
combine(
|
||||
dayChanges(context),
|
||||
permissionsManager.hasPermission(PermissionGroup.Calendar),
|
||||
searchSettings.excludedCalendars,
|
||||
) { now, hasPermission, excludedCalendars -> Triple(now, hasPermission, excludedCalendars) }
|
||||
.collectLatest { (now, hasPermission, excludedCalendars) ->
|
||||
agenda.value = if (!hasPermission) {
|
||||
emptyList()
|
||||
} else withContext(Dispatchers.IO) {
|
||||
calendarRepository.findMany(
|
||||
from = now,
|
||||
to = endOfDay(now),
|
||||
excludeCalendars = excludedCalendars.toList(),
|
||||
).first()
|
||||
.filter { !it.isTask }
|
||||
.sortedBy { it.startTime ?: it.endTime }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the current time when the calendar database changes and periodically, so the
|
||||
* agenda is re-queried when events change or the day rolls over.
|
||||
*/
|
||||
private fun dayChanges(context: Context): Flow<Long> = callbackFlow {
|
||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
trySend(System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
val registered = try {
|
||||
context.contentResolver.registerContentObserver(
|
||||
CalendarContract.Events.CONTENT_URI, true, observer
|
||||
)
|
||||
true
|
||||
} catch (e: SecurityException) {
|
||||
false
|
||||
}
|
||||
trySend(System.currentTimeMillis())
|
||||
val ticker = launch {
|
||||
while (isActive) {
|
||||
delay(RefreshInterval)
|
||||
trySend(System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
awaitClose {
|
||||
ticker.cancel()
|
||||
if (registered) context.contentResolver.unregisterContentObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ranking(events: List<CalendarEvent>, now: Long): Int {
|
||||
if (events.isEmpty()) return 0
|
||||
val soon = events.any { event ->
|
||||
val start = event.startTime
|
||||
!event.allDay && start != null && start <= now + SoonThreshold && event.endTime > now
|
||||
}
|
||||
return if (soon) RankingSoon else RankingToday
|
||||
}
|
||||
|
||||
private fun endOfDay(now: Long): Long {
|
||||
val offset = OffsetDateTime.now().offset
|
||||
return LocalDate.now().plusDays(1).atStartOfDay().toEpochSecond(offset) * 1000
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Component(compactLayout: Boolean) {
|
||||
val events by agenda.collectAsState()
|
||||
val context = LocalContext.current
|
||||
if (events.isEmpty()) return
|
||||
|
||||
val timeFormat = LocalTimeFormat.current
|
||||
val contentColor = LocalContentColor.current
|
||||
|
||||
val visibleCount = if (events.size > MaxRows) MaxRows - 1 else events.size
|
||||
val hiddenCount = events.size - visibleCount
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
for (event in events.take(visibleCount)) {
|
||||
AgendaRow(
|
||||
color = event.color?.let { Color(it) } ?: contentColor,
|
||||
time = event.timeLabel(context, timeFormat),
|
||||
title = event.label,
|
||||
onClick = { event.launch(context, null) },
|
||||
)
|
||||
}
|
||||
if (hiddenCount > 0) {
|
||||
AgendaRow(
|
||||
color = null,
|
||||
time = null,
|
||||
title = pluralStringResource(
|
||||
R.plurals.clockwidget_calendar_part_more_events,
|
||||
hiddenCount,
|
||||
hiddenCount
|
||||
),
|
||||
secondary = true,
|
||||
onClick = { openCalendar(context) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The time of an event, or "all-day" for all-day events. Never a date.
|
||||
*/
|
||||
private fun CalendarEvent.timeLabel(context: Context, timeFormat: TimeFormat): String {
|
||||
if (allDay) return context.getString(R.string.clockwidget_calendar_part_all_day)
|
||||
val start = startTime ?: return ""
|
||||
val skeleton = if (timeFormat.isTwentyFourHours(context)) "HH:mm" else "hh:mm a"
|
||||
return DateFormat.getInstanceForSkeleton(skeleton, ULocale.getDefault()).format(Date(start))
|
||||
}
|
||||
|
||||
/**
|
||||
* One agenda row: a dot in the calendar color, the time and the title. The three columns are
|
||||
* plain layout, they are not drawn.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRow(
|
||||
color: Color?,
|
||||
time: String?,
|
||||
title: String,
|
||||
secondary: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val contentColor = LocalContentColor.current
|
||||
val secondaryColor = contentColor.copy(alpha = contentColor.alpha * 0.7f)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.width(DotColumn),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (color != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(color, CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.width(TimeColumn),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (time != null) {
|
||||
Text(
|
||||
text = time,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = secondaryColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.width(TitleColumn),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (secondary) secondaryColor else contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCalendar(context: Context) {
|
||||
val builder = CalendarContract.CONTENT_URI.buildUpon()
|
||||
builder.appendPath("time")
|
||||
ContentUris.appendId(builder, System.currentTimeMillis())
|
||||
context.tryStartActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(builder.build())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
|
||||
import de.mm20.launcher2.preferences.ui.UiSettings
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
@@ -101,6 +102,32 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
|
||||
settings.setAlarmPart(alarmPart)
|
||||
}
|
||||
|
||||
fun setCalendarPart(calendarPart: Boolean) {
|
||||
settings.setCalendarPart(calendarPart)
|
||||
}
|
||||
|
||||
val slots = settings.slots
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
|
||||
|
||||
fun setSlots(slots: Int) {
|
||||
settings.setSlots(slots)
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of enabled parts. This is the upper bound for the dynamic zone's slot count.
|
||||
*/
|
||||
val partCount = settings.parts
|
||||
.map { parts ->
|
||||
var count = 0
|
||||
if (parts.date) count++
|
||||
if (parts.music) count++
|
||||
if (parts.alarm) count++
|
||||
if (parts.calendar) count++
|
||||
if (parts.battery != BatteryStatusVisibility.Hide) count++
|
||||
count
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
|
||||
|
||||
val alignment = settings.alignment
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||
|
||||
|
||||
@@ -415,7 +415,7 @@
|
||||
<string name="preference_enforce_icon_shape_summary">Anvend form på alle ikoner, inklusive dem, der normalt ikke understøtter det</string>
|
||||
<string name="preference_about_telegram">Telegram-gruppe</string>
|
||||
<string name="preference_about_license_summary">Licenseret under GNU General Public License 3.0</string>
|
||||
<string name="preference_clockwidget_alarm_part_summary">Vis alarmer, der ringer inden for de næste 8 timer</string>
|
||||
<string name="preference_clockwidget_alarm_part_summary">Vis alarmer, der ringer inden for de næste 24 timer</string>
|
||||
<string name="preference_search_favorites_summary">Vis fastgjorte og ofte brugte elementer over app-gitter</string>
|
||||
<string name="preference_search_unitconverter_summary">Anvendelse: 1,5 kg eller 4 cm >> in</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_bottom">Bottom</string>
|
||||
<string name="preference_clockwidget_dynamic_zone">Dynamic zone</string>
|
||||
<string name="preference_clockwidget_dynamic_zone_slots">Visible parts</string>
|
||||
<string name="preference_clockwidget_smartspacer">Managed by Smartspacer</string>
|
||||
<string name="preference_clockwidget_date_part">Date</string>
|
||||
<string name="preference_clockwidget_date_part_summary">Show the current date</string>
|
||||
@@ -611,7 +612,14 @@
|
||||
<string name="preference_clockwidget_battery_part_show">Show when low or charging</string>
|
||||
<string name="preference_clockwidget_battery_part_hide">Off</string>
|
||||
<string name="preference_clockwidget_alarm_part">Alarms</string>
|
||||
<string name="preference_clockwidget_alarm_part_summary">Show alarms that will ring within the next 8 hours</string>
|
||||
<string name="preference_clockwidget_alarm_part_summary">Show alarms that will ring within the next 24 hours</string>
|
||||
<string name="preference_clockwidget_calendar_part">Events</string>
|
||||
<string name="preference_clockwidget_calendar_part_summary">Show today\'s agenda</string>
|
||||
<string name="clockwidget_calendar_part_all_day">all-day</string>
|
||||
<plurals name="clockwidget_calendar_part_more_events">
|
||||
<item quantity="one">+%d more event</item>
|
||||
<item quantity="other">+%d more events</item>
|
||||
</plurals>
|
||||
<string name="preference_screen_backup">Backup and restore</string>
|
||||
<string name="preference_screen_backup_summary">Export and import launcher data</string>
|
||||
<string name="preference_backup">Backup</string>
|
||||
|
||||
@@ -58,6 +58,8 @@ data class LauncherSettingsData internal constructor(
|
||||
val clockWidgetBatteryPart: BatteryStatusVisibility = BatteryStatusVisibility.Show,
|
||||
val clockWidgetMusicPart: Boolean = true,
|
||||
val clockWidgetDatePart: Boolean = true,
|
||||
val clockWidgetCalendarPart: Boolean = false,
|
||||
val clockWidgetDynamicZoneSlots: Int = 1,
|
||||
val clockWidgetFillHeight: Boolean = false,
|
||||
val clockWidgetAlignment: ClockWidgetAlignment = ClockWidgetAlignment.Bottom,
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ data class ClockWidgetParts(
|
||||
val music: Boolean = false,
|
||||
val battery: BatteryStatusVisibility = BatteryStatusVisibility.Hide,
|
||||
val alarm: Boolean = false,
|
||||
val calendar: Boolean = false,
|
||||
)
|
||||
|
||||
class ClockWidgetSettings internal constructor(
|
||||
@@ -37,9 +38,29 @@ class ClockWidgetSettings internal constructor(
|
||||
music = it.clockWidgetMusicPart,
|
||||
battery = it.clockWidgetBatteryPart,
|
||||
alarm = it.clockWidgetAlarmPart,
|
||||
calendar = it.clockWidgetCalendarPart,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* Maximum number of parts the dynamic zone shows at once.
|
||||
*/
|
||||
val slots
|
||||
get() = launcherDataStore.data.map { it.clockWidgetDynamicZoneSlots.coerceAtLeast(1) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
fun setSlots(slots: Int) {
|
||||
launcherDataStore.update {
|
||||
it.copy(clockWidgetDynamicZoneSlots = slots.coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
|
||||
fun setCalendarPart(calendarPart: Boolean) {
|
||||
launcherDataStore.update {
|
||||
it.copy(clockWidgetCalendarPart = calendarPart)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDatePart(datePart: Boolean) {
|
||||
launcherDataStore.update {
|
||||
it.copy(clockWidgetDatePart = datePart)
|
||||
|
||||
87
scripts/ondevice.sh
Executable file
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