fix(edit): recurrence picker review fixes + picker unification (#42) #89
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||||
|
it. Previously it was laid out once for the smallest size and simply stretched
|
||||||
|
when enlarged, so the text stayed small no matter how big you made the widget.
|
||||||
|
Now a bigger widget gets bigger, more readable type and roomier rows, while the
|
||||||
|
default size looks exactly as before — no new setting; it follows the size you
|
||||||
|
already chose ([#51]).
|
||||||
|
|
||||||
## [2.16.0] — 2026-07-17
|
## [2.16.0] — 2026-07-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1047,6 +1055,7 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
|
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||||
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||||
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
||||||
|
|||||||
@@ -63,10 +63,9 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||||
<action android:name="android.intent.action.MAIN" />
|
the two <activity-alias> below, so the app name can be switched at
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Be selectable as the system calendar app. Android has no API for
|
<!-- Be selectable as the system calendar app. Android has no API for
|
||||||
an app to make itself the default, so registering the filters
|
an app to make itself the default, so registering the filters
|
||||||
@@ -165,11 +164,47 @@
|
|||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
</activity>
|
||||||
|
|
||||||
|
<!-- Launcher entry for MainActivity, split into two aliases so the app's
|
||||||
|
launcher name can be switched at runtime between "Calendula" and
|
||||||
|
"Calendar" (issue #44). Exactly one is enabled at a time; the app
|
||||||
|
flips them via PackageManager.setComponentEnabledSetting
|
||||||
|
(LauncherNameManager). The shortcuts meta-data lives here, not on
|
||||||
|
MainActivity, because static shortcuts are published by whichever
|
||||||
|
component owns the MAIN/LAUNCHER filter. -->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".DefaultNameAlias"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".CalendarNameAlias"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name_calendar_alias"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<!-- Standalone surface for a captured crash report. MainActivity routes
|
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.appname
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** The launcher label the app shows for itself (issue #44). */
|
||||||
|
enum class LauncherName { CALENDULA, CALENDAR }
|
||||||
|
|
||||||
|
/** The two launcher aliases declared in the manifest. */
|
||||||
|
enum class LauncherAlias { DEFAULT, CALENDAR }
|
||||||
|
|
||||||
|
/** One component-enable change in a [aliasWritePlan]. */
|
||||||
|
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret the `CalendarNameAlias` component-enabled state as a [LauncherName].
|
||||||
|
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the "Calendar"
|
||||||
|
* name is active; `DEFAULT` (never toggled) and `DISABLED` both resolve to the
|
||||||
|
* manifest default, "Calendula".
|
||||||
|
*/
|
||||||
|
fun launcherNameFor(calendarAliasState: Int): LauncherName =
|
||||||
|
if (calendarAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||||
|
LauncherName.CALENDAR
|
||||||
|
} else {
|
||||||
|
LauncherName.CALENDULA
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ordered enable/disable steps to switch the launcher name to [target].
|
||||||
|
* Always **enables the target alias first, then disables the other** — the two
|
||||||
|
* `setComponentEnabledSetting` calls are not atomic, and this ordering means the
|
||||||
|
* only transient the launcher can observe is a harmless two-entry state, never a
|
||||||
|
* zero-entry one (which would briefly drop the app from the launcher).
|
||||||
|
*/
|
||||||
|
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
|
||||||
|
LauncherName.CALENDAR -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||||
|
)
|
||||||
|
LauncherName.CALENDULA -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the app's launcher label between "Calendula" and "Calendar" by
|
||||||
|
* enabling/disabling the two `<activity-alias>` components (issue #44). The
|
||||||
|
* component-enabled state is the single source of truth — there is no persisted
|
||||||
|
* preference — so [current] reads it straight from [PackageManager] and the two
|
||||||
|
* always agree.
|
||||||
|
*
|
||||||
|
* The decision logic lives in the pure [launcherNameFor] / [aliasWritePlan]
|
||||||
|
* functions above (JVM-tested); this class is only the thin framework seam.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LauncherNameManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val packageManager: PackageManager get() = context.packageManager
|
||||||
|
|
||||||
|
// The class portion is namespace-qualified (suffix-free), the package portion
|
||||||
|
// is the applicationId (which carries the .debug / .releasetest suffix). The
|
||||||
|
// manifest's ".CalendarNameAlias" resolves its class against the namespace, so
|
||||||
|
// the real component is <namespace>.CalendarNameAlias registered under the
|
||||||
|
// suffixed applicationId. Do NOT use ComponentName(context, ".CalendarNameAlias")
|
||||||
|
// — its leading-dot form prepends the applicationId to the class too, producing
|
||||||
|
// "<appId>.CalendarNameAlias" and failing on every debug / releaseTest build.
|
||||||
|
private fun component(alias: LauncherAlias): ComponentName {
|
||||||
|
val simpleName = when (alias) {
|
||||||
|
LauncherAlias.DEFAULT -> "DefaultNameAlias"
|
||||||
|
LauncherAlias.CALENDAR -> "CalendarNameAlias"
|
||||||
|
}
|
||||||
|
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The launcher name currently in effect. */
|
||||||
|
fun current(): LauncherName =
|
||||||
|
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.CALENDAR)))
|
||||||
|
|
||||||
|
/** Switch the launcher name to [name] (no-op cost if already active). */
|
||||||
|
fun set(name: LauncherName) {
|
||||||
|
for (change in aliasWritePlan(name)) {
|
||||||
|
val state = if (change.enabled) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
packageManager.setComponentEnabledSetting(
|
||||||
|
component(change.alias),
|
||||||
|
state,
|
||||||
|
PackageManager.DONT_KILL_APP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* The app's namespace (R-class package) — NOT `applicationId`, which
|
||||||
|
* carries the build-type suffix. Kept in sync with `namespace` in
|
||||||
|
* `app/build.gradle.kts`.
|
||||||
|
*/
|
||||||
|
const val NAMESPACE = "de.jeanlucmakiola.calendula"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.time.LocalDateTime as JavaLocalDateTime
|
||||||
|
|
||||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
internal data class EventWriteTimes(
|
internal data class EventWriteTimes(
|
||||||
@@ -35,7 +37,7 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
timezone = "UTC",
|
timezone = "UTC",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone
|
val writeZone = writeZone(zone)
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
@@ -43,6 +45,30 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone this form's DTSTART is expressed in: UTC for an all-day event (the
|
||||||
|
* provider's date anchor), otherwise the form's own pinned zone, falling back to
|
||||||
|
* [deviceZone] when it doesn't pin one or pins something the tz database can't
|
||||||
|
* parse.
|
||||||
|
*/
|
||||||
|
private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
||||||
|
ZoneOffset.UTC
|
||||||
|
} else {
|
||||||
|
timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's start as a bare wall-clock value — what the user sees on the form,
|
||||||
|
* stripped of any zone. All-day events use their date's midnight rather than
|
||||||
|
* [EventForm.start]'s placeholder time-of-day, which exists only so switching the
|
||||||
|
* event back to timed has something to show.
|
||||||
|
*/
|
||||||
|
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||||
|
start.date.toJavaLocalDate().atStartOfDay()
|
||||||
|
} else {
|
||||||
|
start.toJavaLocalDateTime()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 2445 duration for a recurring event's row (the provider requires
|
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||||
@@ -109,10 +135,12 @@ internal fun buildEventInsertValues(
|
|||||||
* Time fields travel together (the provider validates them as a unit):
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||||
* past occurrences intact when someone edits a later occurrence's time.
|
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
||||||
|
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
||||||
|
* stable across a DST boundary or a zone change between the two.
|
||||||
*/
|
*/
|
||||||
internal fun buildEventUpdateValues(
|
internal fun buildEventUpdateValues(
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
@@ -158,8 +186,23 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.RRULE, null)
|
put(CalendarContract.Events.RRULE, null)
|
||||||
put(CalendarContract.Events.DURATION, null)
|
put(CalendarContract.Events.DURATION, null)
|
||||||
} else {
|
} else {
|
||||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||||
|
// zone — never by a millisecond delta. An instant delta silently bakes in
|
||||||
|
// the offset that happened to apply on the edited occurrence's date, which
|
||||||
|
// is a different offset from the series anchor's whenever a DST boundary
|
||||||
|
// sits between them, or whenever the zone itself changed. Working in wall
|
||||||
|
// clock keeps "09:00" meaning 09:00 at both ends.
|
||||||
|
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
|
||||||
|
.atZone(original.writeZone(zone)).toLocalDateTime()
|
||||||
|
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||||
|
val shifted = seriesLocal.plus(wallClockShift)
|
||||||
|
// An all-day series anchor must sit on a UTC midnight. A pure day move
|
||||||
|
// already lands there (both ends are midnights), but *switching* a
|
||||||
|
// recurring event to all-day shifts by a time-of-day too, so snap.
|
||||||
|
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
|
||||||
|
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
|
||||||
|
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
|
||||||
put(CalendarContract.Events.DTEND, null)
|
put(CalendarContract.Events.DTEND, null)
|
||||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||||
|
|||||||
@@ -31,8 +31,33 @@ data class TimeZoneOption(
|
|||||||
|
|
||||||
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
||||||
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four fields [filterTimeZones] matches on, normalized once here rather
|
||||||
|
* than per query. Normalizing is not cheap — NFD decomposition plus a combining-
|
||||||
|
* mark strip — and a search re-examines every option on every keystroke, so
|
||||||
|
* doing it at construction turns ~2400 normalizations per character into a few
|
||||||
|
* hundred plain `startsWith`/`contains` calls.
|
||||||
|
*
|
||||||
|
* Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`:
|
||||||
|
* it is derived state, and two options with the same id are the same option.
|
||||||
|
*/
|
||||||
|
internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys(
|
||||||
|
city = city.normalizeForSearch(),
|
||||||
|
id = id.normalizeForSearch(),
|
||||||
|
displayName = displayName.normalizeForSearch(),
|
||||||
|
shortName = shortName.normalizeForSearch(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pre-normalized match targets for one [TimeZoneOption]. */
|
||||||
|
internal data class TimeZoneSearchKeys(
|
||||||
|
val city: String,
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val shortName: String,
|
||||||
|
)
|
||||||
|
|
||||||
private fun optionFor(
|
private fun optionFor(
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
@@ -87,9 +112,13 @@ private fun resolveAbbreviation(
|
|||||||
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
|
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it
|
* Every zone the JVM knows, resolved at [at]. [regionOf] (see
|
||||||
* once and filter the result rather than rebuilding per keystroke. [regionOf]
|
* [resolveAbbreviation]) supplies the zone's region for the abbreviation.
|
||||||
* (see [resolveAbbreviation]) supplies the zone's region for the abbreviation.
|
*
|
||||||
|
* This is ~600 entries, each costing a localized display name plus up to two ICU
|
||||||
|
* short-name lookups, so it is **not** cheap enough for the main thread — build
|
||||||
|
* it off-thread once and filter the result with [filterTimeZones] rather than
|
||||||
|
* rebuilding per keystroke.
|
||||||
*
|
*
|
||||||
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||||
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
||||||
@@ -154,18 +183,15 @@ fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZone
|
|||||||
if (needle.isEmpty()) return options
|
if (needle.isEmpty()) return options
|
||||||
return options
|
return options
|
||||||
.mapNotNull { option ->
|
.mapNotNull { option ->
|
||||||
val city = option.city.normalizeForSearch()
|
val keys = option.searchKeys
|
||||||
val id = option.id.normalizeForSearch()
|
|
||||||
val name = option.displayName.normalizeForSearch()
|
|
||||||
val abbrev = option.shortName.normalizeForSearch()
|
|
||||||
val rank = when {
|
val rank = when {
|
||||||
city.startsWith(needle) -> 0
|
keys.city.startsWith(needle) -> 0
|
||||||
abbrev == needle -> 1
|
keys.shortName == needle -> 1
|
||||||
name.startsWith(needle) -> 2
|
keys.displayName.startsWith(needle) -> 2
|
||||||
abbrev.startsWith(needle) -> 3
|
keys.shortName.startsWith(needle) -> 3
|
||||||
city.contains(needle) -> 4
|
keys.city.contains(needle) -> 4
|
||||||
id.contains(needle) -> 5
|
keys.id.contains(needle) -> 5
|
||||||
name.contains(needle) -> 6
|
keys.displayName.contains(needle) -> 6
|
||||||
else -> return@mapNotNull null
|
else -> return@mapNotNull null
|
||||||
}
|
}
|
||||||
rank to option
|
rank to option
|
||||||
@@ -174,6 +200,9 @@ fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZone
|
|||||||
.map { it.second }
|
.map { it.second }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unicode combining marks — what NFD decomposition leaves an accent as. */
|
||||||
|
private val COMBINING_MARKS = Regex("\\p{Mn}+")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
||||||
* a query types the way a place is spoken rather than the way the tz database
|
* a query types the way a place is spoken rather than the way the tz database
|
||||||
@@ -181,7 +210,7 @@ fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZone
|
|||||||
*/
|
*/
|
||||||
private fun String.normalizeForSearch(): String =
|
private fun String.normalizeForSearch(): String =
|
||||||
Normalizer.normalize(this, Normalizer.Form.NFD)
|
Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||||
.replace(Regex("\\p{Mn}+"), "")
|
.replace(COMBINING_MARKS, "")
|
||||||
.replace('_', ' ')
|
.replace('_', ' ')
|
||||||
.replace('/', ' ')
|
.replace('/', ' ')
|
||||||
.lowercase(Locale.ROOT)
|
.lowercase(Locale.ROOT)
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
// No file-level zone constant here on purpose: it would be fixed for the process
|
||||||
|
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||||
|
// time-zone change. The zone travels on AgendaUiState.Success instead.
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -332,6 +334,7 @@ private fun AgendaContent(
|
|||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
zone = state.zone,
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -348,6 +351,7 @@ private fun AgendaContent(
|
|||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -379,6 +383,7 @@ private fun AgendaList(
|
|||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
day = day.date,
|
day = day.date,
|
||||||
|
zone = zone,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion(),
|
||||||
@@ -458,6 +463,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) {
|
|||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
day: LocalDate,
|
day: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
position: Position,
|
position: Position,
|
||||||
dimmed: Boolean,
|
dimmed: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -469,7 +475,7 @@ private fun AgendaEventRow(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = title,
|
title = title,
|
||||||
summary = agendaTimeSummary(event, day),
|
summary = agendaTimeSummary(event, day, zone),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
leading = {
|
leading = {
|
||||||
@@ -575,25 +581,34 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
|||||||
* An all-day multi-day event is simply "All day" on every day it covers.
|
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String {
|
private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||||
val is24Hour = LocalUse24HourFormat.current
|
val is24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
|
||||||
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||||
is AgendaTimeLabel.Starts ->
|
is AgendaTimeLabel.Starts -> stringResource(
|
||||||
stringResource(R.string.agenda_span_starts, formatTime(label.start, is24Hour, locale))
|
R.string.agenda_span_starts,
|
||||||
is AgendaTimeLabel.Ends ->
|
formatTime(label.start, zone, is24Hour, locale),
|
||||||
stringResource(R.string.agenda_span_ends, formatTime(label.end, is24Hour, locale))
|
)
|
||||||
is AgendaTimeLabel.Range ->
|
is AgendaTimeLabel.Ends -> stringResource(
|
||||||
"${formatTime(label.start, is24Hour, locale)} – ${formatTime(label.end, is24Hour, locale)}"
|
R.string.agenda_span_ends,
|
||||||
|
formatTime(label.end, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||||
|
formatTime(label.end, zone, is24Hour, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
return if (location != null) "$time · $location" else time
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
|
private fun formatTime(
|
||||||
|
instant: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
|
is24Hour: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
val t = instant.toLocalDateTime(zone).time
|
val t = instant.toLocalDateTime(zone).time
|
||||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,5 +160,12 @@ sealed interface AgendaUiState {
|
|||||||
val rangeEnd: LocalDate,
|
val rangeEnd: LocalDate,
|
||||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||||
val showRangeBar: Boolean,
|
val showRangeBar: Boolean,
|
||||||
|
/**
|
||||||
|
* The zone [days] were grouped in. Carried in the state rather than
|
||||||
|
* re-read by the screen so labelling and grouping cannot disagree: an
|
||||||
|
* event's "Starts …/Ends …/All day" line is only correct relative to the
|
||||||
|
* same zone that decided which day it was filed under.
|
||||||
|
*/
|
||||||
|
val zone: TimeZone,
|
||||||
) : AgendaUiState
|
) : AgendaUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
rangeIsOverride = params.rangeIsOverride,
|
rangeIsOverride = params.rangeIsOverride,
|
||||||
rangeEnd = rangeEnd,
|
rangeEnd = rangeEnd,
|
||||||
showRangeBar = params.showRangeBar,
|
showRangeBar = params.showRangeBar,
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,19 @@ import java.util.Locale
|
|||||||
* sits directly above a grid that already says which year it is, and the year's
|
* sits directly above a grid that already says which year it is, and the year's
|
||||||
* *absence* is itself the signal that you're in the current one — it appears the
|
* *absence* is itself the signal that you're in the current one — it appears the
|
||||||
* moment you page out of it, which is when it starts carrying information.
|
* moment you page out of it, which is when it starts carrying information.
|
||||||
|
*
|
||||||
|
* [forceYear] overrides that for titles whose [date] does not tell the whole
|
||||||
|
* story — a week view's title names only the month its *first* day falls in, so
|
||||||
|
* a week straddling New Year must still show the year even though [date] is in
|
||||||
|
* [currentYear].
|
||||||
*/
|
*/
|
||||||
fun formatCalendarTitle(
|
fun formatCalendarTitle(
|
||||||
date: java.time.LocalDate,
|
date: java.time.LocalDate,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
skeleton: String,
|
skeleton: String,
|
||||||
|
forceYear: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
val fields = if (date.year == currentYear) skeleton else skeleton + "y"
|
val fields = if (date.year == currentYear && !forceYear) skeleton else skeleton + "y"
|
||||||
return localizedDateFormatter(locale, fields).format(date)
|
return localizedDateFormatter(locale, fields).format(date)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -34,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||||
import de.jeanlucmakiola.calendula.domain.filterTimeZones
|
import de.jeanlucmakiola.calendula.domain.filterTimeZones
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
@@ -43,6 +45,8 @@ import de.jeanlucmakiola.floret.components.Position
|
|||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen zone picker: a query field over every zone the JVM knows, with the
|
* Full-screen zone picker: a query field over every zone the JVM knows, with the
|
||||||
@@ -64,14 +68,28 @@ fun TimeZonePickerDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var query by rememberSaveable { mutableStateOf("") }
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
// ~600 zones, each resolving a localized name and a DST-aware offset: build
|
|
||||||
// once per open, then filter the result per keystroke.
|
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val allZones = remember(locale) { timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion) }
|
// ~600 zones, each resolving a localized name and up to two ICU short names:
|
||||||
|
// far too much to run inside composition, so build it on a worker and let the
|
||||||
|
// list fill in a frame later. Filtering the built catalogue is cheap (its
|
||||||
|
// search keys are pre-normalized), so that stays here.
|
||||||
|
val allZones by produceState(initialValue = emptyList<TimeZoneOption>(), locale) {
|
||||||
|
value = withContext(Dispatchers.Default) {
|
||||||
|
timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion)
|
||||||
|
}
|
||||||
|
}
|
||||||
val matches = remember(allZones, query) { filterTimeZones(allZones, query) }
|
val matches = remember(allZones, query) { filterTimeZones(allZones, query) }
|
||||||
val recentZones = remember(allZones, recents) {
|
val recentZones = remember(allZones, recents) {
|
||||||
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
|
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
|
||||||
}
|
}
|
||||||
|
// Resolved on its own rather than looked up in the catalogue: it's one zone,
|
||||||
|
// so it costs nothing, and the device row can then render complete on the
|
||||||
|
// first frame instead of showing a bare id until the catalogue lands.
|
||||||
|
val deviceSummary = remember(deviceZoneId, locale) {
|
||||||
|
timeZoneOptionOf(deviceZoneId, locale, regionOf = ::icuTimeZoneRegion)
|
||||||
|
?.let { zoneDescriptor(it) }
|
||||||
|
?: deviceZoneId
|
||||||
|
}
|
||||||
val searching = query.isNotBlank()
|
val searching = query.isNotBlank()
|
||||||
|
|
||||||
fun choose(zoneId: String?) {
|
fun choose(zoneId: String?) {
|
||||||
@@ -97,7 +115,7 @@ fun TimeZonePickerDialog(
|
|||||||
item(key = "device") {
|
item(key = "device") {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.event_edit_timezone_device),
|
title = stringResource(R.string.event_edit_timezone_device),
|
||||||
summary = zoneSummary(deviceZoneId, allZones),
|
summary = deviceSummary,
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
selected = selected == null,
|
selected = selected == null,
|
||||||
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
||||||
@@ -130,7 +148,10 @@ fun TimeZonePickerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searching && matches.isEmpty()) {
|
// allZones.isNotEmpty() gates this: until the catalogue lands there is
|
||||||
|
// simply nothing to match yet, and claiming "no time zone matches"
|
||||||
|
// for that frame would be wrong.
|
||||||
|
if (searching && matches.isEmpty() && allZones.isNotEmpty()) {
|
||||||
item(key = "empty") {
|
item(key = "empty") {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.event_edit_timezone_none, query),
|
text = stringResource(R.string.event_edit_timezone_none, query),
|
||||||
@@ -244,9 +265,3 @@ private fun ZoneRow(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "CET · GMT+01:00" for [zoneId], or the bare id if the catalogue lacks it. */
|
|
||||||
private fun zoneSummary(zoneId: String, allZones: List<TimeZoneOption>): String =
|
|
||||||
allZones.firstOrNull { it.id == zoneId }
|
|
||||||
?.let { zoneDescriptor(it) }
|
|
||||||
?: zoneId
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
@@ -463,8 +464,8 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
// Same hierarchy as the When card above — the label carries the card,
|
// Same hierarchy as the When card above — the label carries the card,
|
||||||
// the time sits small beneath it. The local time is the one the reader
|
// the time sits small beneath it. The local time is the one the reader
|
||||||
// acts on, so the original must stay quieter than it, not compete.
|
// acts on, so the original must stay quieter than it, not compete.
|
||||||
val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) {
|
val foreignZone = remember(detail.eventTimezone, instance.isAllDay, instance.start, locale) {
|
||||||
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale)
|
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start)
|
||||||
}
|
}
|
||||||
foreignZone?.let { zoneOption ->
|
foreignZone?.let { zoneOption ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -789,11 +790,26 @@ private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel
|
|||||||
* pinned to a zone different from the device's — the cases where showing it
|
* pinned to a zone different from the device's — the cases where showing it
|
||||||
* removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
|
* removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
|
||||||
* tz database doesn't know).
|
* tz database doesn't know).
|
||||||
|
*
|
||||||
|
* Resolved *at [at]*, the event's own start, not at "now": the abbreviation and
|
||||||
|
* offset both move with DST, and the card prints them next to the event's time.
|
||||||
|
* Resolving at now would label a July event "CET · 10:00 AM" when read in
|
||||||
|
* January — an abbreviation that contradicts the time beside it.
|
||||||
*/
|
*/
|
||||||
private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? {
|
private fun foreignTimeZone(
|
||||||
|
tz: String?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
at: Instant,
|
||||||
|
): TimeZoneOption? {
|
||||||
if (isAllDay || tz.isNullOrBlank()) return null
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
if (tz == ZoneId.systemDefault().id) return null
|
if (tz == ZoneId.systemDefault().id) return null
|
||||||
return timeZoneOptionOf(tz, locale, regionOf = ::icuTimeZoneRegion)
|
return timeZoneOptionOf(
|
||||||
|
tz,
|
||||||
|
locale,
|
||||||
|
at = at.toJavaInstant(),
|
||||||
|
regionOf = ::icuTimeZoneRegion,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import androidx.compose.material.icons.filled.Person
|
|||||||
import androidx.compose.material.icons.filled.PersonAdd
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Public
|
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
@@ -165,8 +164,10 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.isoDayNumber
|
import kotlinx.datetime.isoDayNumber
|
||||||
import kotlinx.datetime.toJavaDayOfWeek
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toJavaLocalTime
|
import kotlinx.datetime.toJavaLocalTime
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
@@ -684,7 +685,13 @@ private fun EventEditContent(
|
|||||||
// whoever is reading it. Spell the local equivalent out rather than
|
// whoever is reading it. Spell the local equivalent out rather than
|
||||||
// leaving the user to do the offset arithmetic. Absent (null) unless
|
// leaving the user to do the offset arithmetic. Absent (null) unless
|
||||||
// the event is pinned somewhere other than here.
|
// the event is pinned somewhere other than here.
|
||||||
form.timesIn(TimeZone.currentSystemDefault())?.let { (localStart, localEnd) ->
|
// Keyed rather than recomputed: this sits in the same Column as the
|
||||||
|
// title field, so it would otherwise re-parse the zone on every
|
||||||
|
// keystroke.
|
||||||
|
val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) {
|
||||||
|
form.timesIn(TimeZone.currentSystemDefault())
|
||||||
|
}
|
||||||
|
localTimes?.let { (localStart, localEnd) ->
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = stringResource(
|
||||||
@@ -742,8 +749,20 @@ private fun EventEditContent(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
val pinned = remember(form.timezone, locale) {
|
// Resolved at the event's own start, not at "now": the
|
||||||
form.timezone?.let { timeZoneOptionOf(it, locale, regionOf = ::icuTimeZoneRegion) }
|
// abbreviation and offset shown ("CEST · GMT+02:00") must be
|
||||||
|
// the ones that apply when the event actually happens.
|
||||||
|
val pinned = remember(form.timezone, form.start, locale) {
|
||||||
|
form.timezone
|
||||||
|
?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() }
|
||||||
|
?.let { (id, zone) ->
|
||||||
|
timeZoneOptionOf(
|
||||||
|
id,
|
||||||
|
locale,
|
||||||
|
at = form.start.toInstant(zone).toJavaInstant(),
|
||||||
|
regionOf = ::icuTimeZoneRegion,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import androidx.compose.animation.slideInHorizontally
|
|||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -42,6 +44,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.Cake
|
import androidx.compose.material.icons.filled.Cake
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
import androidx.compose.material.icons.filled.DragHandle
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
import androidx.compose.material.icons.filled.SwapVert
|
import androidx.compose.material.icons.filled.SwapVert
|
||||||
@@ -74,6 +77,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
@@ -92,6 +96,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
@@ -487,8 +492,10 @@ private fun AppearanceScreen(
|
|||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
var showBrandFont by remember { mutableStateOf(false) }
|
var showBrandFont by remember { mutableStateOf(false) }
|
||||||
var showPlainFont by remember { mutableStateOf(false) }
|
var showPlainFont by remember { mutableStateOf(false) }
|
||||||
|
var showAppName by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||||
|
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
|
||||||
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||||
@@ -679,8 +686,58 @@ private fun AppearanceScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// App name — chooses the launcher label between "Calendula" and "Calendar"
|
||||||
|
// (issue #44). Own group: it's a launcher/system concern, not calendar
|
||||||
|
// formatting. A sub-page chooser (not a switch), matching the app's other
|
||||||
|
// "choose one" settings and leaving room for more names later.
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
summary = launcherNameLabel(launcherName),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showAppName = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showAppName) {
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
onDismiss = { showAppName = false },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
// Show both names as launcher-mark previews so the user sees what
|
||||||
|
// they'd switch to, not just the current state. Tapping applies
|
||||||
|
// immediately and highlights — the picker stays open so the change is
|
||||||
|
// visible; back exits.
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_app_name_summary),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
LauncherName.entries.forEach { option ->
|
||||||
|
AppNameOptionCard(
|
||||||
|
name = option,
|
||||||
|
selected = launcherName == option,
|
||||||
|
onClick = { viewModel.setLauncherName(option) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
@@ -1698,6 +1755,98 @@ private fun openUrl(context: Context, url: String) {
|
|||||||
runCatching { context.startActivity(intent) }
|
runCatching { context.startActivity(intent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The display name for a launcher-label choice (issue #44). */
|
||||||
|
@Composable
|
||||||
|
private fun launcherNameLabel(name: LauncherName): String = stringResource(
|
||||||
|
when (name) {
|
||||||
|
LauncherName.CALENDULA -> R.string.app_name
|
||||||
|
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable launcher-name preview in the App name picker (issue #44): the
|
||||||
|
* app's launcher mark over the name, framed as a card. The active one carries a
|
||||||
|
* primary border, a tinted container and a check; tapping selects it. The mark
|
||||||
|
* is the same for both — only the label changes — so the card previews exactly
|
||||||
|
* what the home screen will read.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppNameOptionCard(
|
||||||
|
name: LauncherName,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
val borderColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
}
|
||||||
|
val containerColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(containerColor)
|
||||||
|
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 20.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// The adaptive launcher mark, reconstructed as a squircle (as in the
|
||||||
|
// onboarding BrandHero) so it renders identically everywhere.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = launcherNameLabel(name),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
// Selection indicator: a filled check when active, an empty ring otherwise.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
when (mode) {
|
when (mode) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherNameManager
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
@@ -43,7 +45,9 @@ import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
|||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -62,6 +66,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
|
private val launcherNameManager: LauncherNameManager,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -202,6 +207,28 @@ class SettingsViewModel @Inject constructor(
|
|||||||
initialValue = AppFontSettings(),
|
initialValue = AppFontSettings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher-label choice (issue #44). Backed by the manifest aliases'
|
||||||
|
* component-enabled state rather than a stored preference, so it's read
|
||||||
|
* imperatively via [LauncherNameManager] and held here in its own flow — the
|
||||||
|
* main settings combine is already at its arity limit and this isn't a
|
||||||
|
* DataStore flow anyway.
|
||||||
|
*
|
||||||
|
* Seeded with the manifest default and corrected off-thread rather than read
|
||||||
|
* in the initializer: `getComponentEnabledSetting` is a binder round-trip to
|
||||||
|
* system_server, and this ViewModel is constructed on the main thread while
|
||||||
|
* Settings is opening. The row it feeds is well below the fold, so the
|
||||||
|
* one-frame correction is never visible.
|
||||||
|
*/
|
||||||
|
private val _launcherName = MutableStateFlow(LauncherName.CALENDULA)
|
||||||
|
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_launcherName.value = withContext(io) { launcherNameManager.current() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Emitted when a picked font file couldn't be read as a font; the screen
|
// Emitted when a picked font file couldn't be read as a font; the screen
|
||||||
// surfaces it and the previous selection stays put.
|
// surfaces it and the previous selection stays put.
|
||||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
@@ -469,6 +496,22 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch the launcher label between "Calendula" and "Calendar" (issue #44).
|
||||||
|
* The card highlights immediately, then settles on whatever the component
|
||||||
|
* state actually reports — the two `setComponentEnabledSetting` calls are
|
||||||
|
* binder round-trips, so they don't belong on the main thread either.
|
||||||
|
*/
|
||||||
|
fun setLauncherName(name: LauncherName) {
|
||||||
|
_launcherName.value = name
|
||||||
|
viewModelScope.launch {
|
||||||
|
_launcherName.value = withContext(io) {
|
||||||
|
launcherNameManager.set(name)
|
||||||
|
launcherNameManager.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setPastEventDisplay(mode)
|
prefs.setPastEventDisplay(mode)
|
||||||
|
|||||||
@@ -880,13 +880,21 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
|||||||
* month of [weekStart] means a week straddling a boundary keeps the outgoing
|
* month of [weekStart] means a week straddling a boundary keeps the outgoing
|
||||||
* month until it is fully gone — a week is seven contiguous days, so the earlier
|
* month until it is fully gone — a week is seven contiguous days, so the earlier
|
||||||
* month has a day in it exactly while [weekStart] is still inside it. That also
|
* month has a day in it exactly while [weekStart] is still inside it. That also
|
||||||
* makes the title depend on nothing but [weekStart], so it cannot drift with the
|
* makes the *month* depend on nothing but [weekStart], so it cannot drift with
|
||||||
* direction you paged in from.
|
* the direction you paged in from.
|
||||||
|
*
|
||||||
|
* The *year* is decided from the whole week, not just its start: a week running
|
||||||
|
* Dec 28 – Jan 3 has four of its seven visible columns in the new year, so
|
||||||
|
* "December" with no year would be actively misleading while the reader is
|
||||||
|
* looking straight at January dates.
|
||||||
*/
|
*/
|
||||||
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String =
|
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
|
||||||
formatCalendarTitle(
|
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
|
return formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = "LLLL",
|
skeleton = "LLLL",
|
||||||
|
forceYear = weekEnd.year != weekStart.year,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size tiers a widget scales its typography and metrics across (#51).
|
||||||
|
*
|
||||||
|
* Shared by every Glance widget so the bucketing rule can't drift between them:
|
||||||
|
* each widget keeps its own metrics table, but they all agree on *when* a widget
|
||||||
|
* counts as compact, regular, large or extra-large. Both widgets already declare
|
||||||
|
* [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live
|
||||||
|
* size via `LocalSize.current` and passes it to [scaleFor].
|
||||||
|
*
|
||||||
|
* Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is
|
||||||
|
* covered by plain JVM tests.
|
||||||
|
*/
|
||||||
|
internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome a widget spends before its first content row: outer vertical padding
|
||||||
|
* plus a header row and its spacer. Subtracted from the raw height so the height
|
||||||
|
* thresholds below talk about *usable* space rather than gross widget height.
|
||||||
|
*/
|
||||||
|
private val CHROME_HEIGHT = 60.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buckets a live widget size into a [WidgetScale] by **width**.
|
||||||
|
*
|
||||||
|
* Width is the right axis: it governs how much of a title fits on a row, so it's
|
||||||
|
* what should drive type size. Height only decides how many rows are visible — a
|
||||||
|
* tall, narrow widget wants *more events*, not bigger text — so it never raises
|
||||||
|
* the tier. It does act as a **cap**, though: a genuinely squashed widget is
|
||||||
|
* stepped back down so it can't keep oversized type in a sliver of space.
|
||||||
|
*
|
||||||
|
* The width thresholds are spread across the range a phone can actually produce
|
||||||
|
* (~180dp up to roughly the screen width) rather than over a theoretical range,
|
||||||
|
* so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
|
||||||
|
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
|
||||||
|
* full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide
|
||||||
|
* surfaces — tablets, foldables, landscape — where the extra size reads well.
|
||||||
|
*
|
||||||
|
* The height cap is deliberately generous: it exists to catch a widget squashed
|
||||||
|
* to one or two rows, **not** to gate ordinary placements. A full-width widget at
|
||||||
|
* the usual three cells tall (~270dp) must still reach the tier its width earned
|
||||||
|
* — that is exactly the resize #51 reports, and an aggressive cap would make the
|
||||||
|
* whole feature a no-op for it.
|
||||||
|
*/
|
||||||
|
internal fun scaleFor(size: DpSize): WidgetScale {
|
||||||
|
val byWidth = when {
|
||||||
|
size.width < 260.dp -> WidgetScale.COMPACT
|
||||||
|
size.width < 330.dp -> WidgetScale.REGULAR
|
||||||
|
size.width < 420.dp -> WidgetScale.LARGE
|
||||||
|
else -> WidgetScale.XLARGE
|
||||||
|
}
|
||||||
|
// Height can only ever pull the tier *down*, never push it up: a squashed
|
||||||
|
// widget would otherwise keep the big type its width earned and look absurd
|
||||||
|
// in the little space left. Keeping this a cap (rather than a second scaling
|
||||||
|
// axis) is what preserves "tall and narrow shows more events, not bigger
|
||||||
|
// text". Thresholds are usable height — roughly one, two and three rows of
|
||||||
|
// breathing room once the header is paid for.
|
||||||
|
val usable = size.height - CHROME_HEIGHT
|
||||||
|
val heightCap = when {
|
||||||
|
usable < 70.dp -> WidgetScale.COMPACT
|
||||||
|
usable < 130.dp -> WidgetScale.REGULAR
|
||||||
|
usable < 200.dp -> WidgetScale.LARGE
|
||||||
|
else -> WidgetScale.XLARGE
|
||||||
|
}
|
||||||
|
return minOf(byWidth, heightCap)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal layout constants for an agenda event row. These don't scale with the
|
||||||
|
* tier — a wider stripe or gap would eat title width, which is the thing the row
|
||||||
|
* is short of — but [TEXT_INDENT] is *derived* from them so the day header and
|
||||||
|
* the "nothing left today" line can never drift out of alignment with the event
|
||||||
|
* title column the way a hardcoded 19dp could.
|
||||||
|
*/
|
||||||
|
internal val ROW_H_PAD = 4.dp
|
||||||
|
internal val STRIPE_WIDTH = 5.dp
|
||||||
|
internal val STRIPE_GAP = 10.dp
|
||||||
|
|
||||||
|
/** Left indent that lines non-event text up with the event title column. */
|
||||||
|
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The width band a *default* agenda placement can land in, per
|
||||||
|
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`,
|
||||||
|
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but
|
||||||
|
* launcher cell grids vary, so the whole band — not one measured point — has to
|
||||||
|
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
|
||||||
|
* to hold. A test pins that.
|
||||||
|
*
|
||||||
|
* If the provider's `targetCellWidth` ever changes, this band and the first
|
||||||
|
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
|
||||||
|
* revisited together.
|
||||||
|
*/
|
||||||
|
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
|
||||||
|
* grow (the horizontal row constants above, corner radii) stay constants rather
|
||||||
|
* than routing through here.
|
||||||
|
*/
|
||||||
|
internal data class AgendaMetrics(
|
||||||
|
val title: TextUnit, // header "Upcoming"
|
||||||
|
val dayHeader: TextUnit, // day label ("Today · …")
|
||||||
|
val eventTitle: TextUnit, // event title line
|
||||||
|
val eventTime: TextUnit, // time/location line
|
||||||
|
val placeholder: TextUnit, // "no more events today" (#35)
|
||||||
|
val message: TextUnit, // empty/permission centred message
|
||||||
|
val stripeH: Dp, // coloured event stripe height
|
||||||
|
val iconImage: Dp, // header action icon glyph
|
||||||
|
val iconBox: Dp, // header action touch target
|
||||||
|
val rowVPad: Dp, // event row vertical padding
|
||||||
|
val dayHeaderTopPad: Dp, // space above a day header
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Resolves the stripe height against the user's system font scale.
|
||||||
|
*
|
||||||
|
* The stripe is a [Dp] but the two text lines it sits beside are `sp`, so
|
||||||
|
* they scale with the accessibility font setting and the stripe does not —
|
||||||
|
* at "Largest" the text outgrows the stripe and it visibly under-runs the row
|
||||||
|
* it is supposed to mark. Multiplying by the same factor keeps them locked,
|
||||||
|
* and at the default scale of 1.0 reproduces the tier's value exactly.
|
||||||
|
*/
|
||||||
|
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||||
|
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Type sizes are anchored to the Material 3 type scale (see the `material-3`
|
||||||
|
* skill's typography reference) rather than invented: 16sp is Title Medium, 14sp
|
||||||
|
* Body Medium / Title Small, 12sp Body Small, 16sp Body Large, 22sp Title Large.
|
||||||
|
*
|
||||||
|
* Two documented deviations:
|
||||||
|
*
|
||||||
|
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately —
|
||||||
|
* COMPACT reproduces the widget's original constants verbatim so a
|
||||||
|
* default-sized widget looks exactly as it did (#51), and snapping it to
|
||||||
|
* Title Small (14sp) would break that promise for a 1sp gain.
|
||||||
|
*
|
||||||
|
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
|
||||||
|
* which is far too coarse for four widget tiers. Where a role would force a
|
||||||
|
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and
|
||||||
|
* mark it. The endpoints stay on real roles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private val COMPACT_METRICS = AgendaMetrics(
|
||||||
|
title = 16.sp, // M3 Title Medium
|
||||||
|
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
||||||
|
eventTitle = 14.sp, // M3 Body Medium
|
||||||
|
eventTime = 12.sp, // M3 Body Small
|
||||||
|
placeholder = 14.sp, // M3 Body Medium
|
||||||
|
message = 14.sp, // M3 Body Medium
|
||||||
|
stripeH = 36.dp,
|
||||||
|
iconImage = 22.dp,
|
||||||
|
iconBox = 40.dp,
|
||||||
|
rowVPad = 4.dp,
|
||||||
|
dayHeaderTopPad = 10.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val REGULAR_METRICS = AgendaMetrics(
|
||||||
|
title = 18.sp, // †
|
||||||
|
dayHeader = 14.sp, // M3 Title Small
|
||||||
|
eventTitle = 16.sp, // M3 Body Large
|
||||||
|
eventTime = 14.sp, // M3 Body Medium
|
||||||
|
placeholder = 16.sp, // M3 Body Large
|
||||||
|
message = 16.sp, // M3 Body Large
|
||||||
|
stripeH = 40.dp,
|
||||||
|
iconImage = 24.dp,
|
||||||
|
iconBox = 44.dp,
|
||||||
|
rowVPad = 5.dp,
|
||||||
|
dayHeaderTopPad = 11.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LARGE_METRICS = AgendaMetrics(
|
||||||
|
title = 20.sp, // †
|
||||||
|
dayHeader = 16.sp, // M3 Title Medium
|
||||||
|
eventTitle = 18.sp, // †
|
||||||
|
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
|
||||||
|
placeholder = 18.sp, // † on purpose, so the title keeps its
|
||||||
|
message = 18.sp, // † lead and the hierarchy survives.
|
||||||
|
stripeH = 46.dp,
|
||||||
|
iconImage = 26.dp,
|
||||||
|
iconBox = 48.dp,
|
||||||
|
rowVPad = 6.dp,
|
||||||
|
dayHeaderTopPad = 12.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val XLARGE_METRICS = AgendaMetrics(
|
||||||
|
title = 22.sp, // M3 Title Large
|
||||||
|
dayHeader = 18.sp, // †
|
||||||
|
eventTitle = 20.sp, // †
|
||||||
|
eventTime = 16.sp, // M3 Body Large
|
||||||
|
placeholder = 20.sp, // †
|
||||||
|
message = 20.sp, // †
|
||||||
|
stripeH = 52.dp,
|
||||||
|
iconImage = 28.dp,
|
||||||
|
iconBox = 52.dp,
|
||||||
|
rowVPad = 8.dp,
|
||||||
|
dayHeaderTopPad = 14.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */
|
||||||
|
private val AGENDA_METRICS = listOf(
|
||||||
|
COMPACT_METRICS,
|
||||||
|
REGULAR_METRICS,
|
||||||
|
LARGE_METRICS,
|
||||||
|
XLARGE_METRICS,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal]
|
||||||
@@ -5,7 +5,6 @@ import android.content.res.Configuration
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
@@ -14,9 +13,11 @@ import androidx.glance.GlanceModifier
|
|||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.Image
|
import androidx.glance.Image
|
||||||
import androidx.glance.ImageProvider
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalSize
|
||||||
import androidx.glance.action.ActionParameters
|
import androidx.glance.action.ActionParameters
|
||||||
import androidx.glance.action.clickable
|
import androidx.glance.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
@@ -61,6 +62,7 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
@@ -107,6 +109,19 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
// Exact so the composition sees the widget's live size and can scale type/rows
|
||||||
|
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
|
||||||
|
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the
|
||||||
|
// same.
|
||||||
|
//
|
||||||
|
// Note Exact still asks Glance for one RemoteViews per host size (typically
|
||||||
|
// portrait + landscape) where the old SizeMode.Single produced exactly one —
|
||||||
|
// so the serialized payload roughly doubles. That is why the row list below is
|
||||||
|
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS
|
||||||
|
// = 365) could otherwise push the RemoteViews past the binder transaction
|
||||||
|
// limit and the host would just show "Problem loading widget".
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val data = context.loadAgendaWidgetData()
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
@@ -126,6 +141,15 @@ class RefreshAgendaAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||||
|
* stays well inside the binder transaction limit regardless of range and calendar
|
||||||
|
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
|
||||||
|
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||||
|
* risking a failed update for.
|
||||||
|
*/
|
||||||
|
private const val MAX_AGENDA_ROWS = 100
|
||||||
|
|
||||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||||
private sealed interface AgendaRow {
|
private sealed interface AgendaRow {
|
||||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||||
@@ -136,16 +160,22 @@ private sealed interface AgendaRow {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||||
|
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
|
||||||
|
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
|
||||||
|
// The stripe is then re-resolved against the system font scale so it tracks the
|
||||||
|
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||||
|
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||||
|
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale)
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(GlanceTheme.colors.surface)
|
.background(GlanceTheme.colors.surface)
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
AgendaHeader()
|
AgendaHeader(metrics)
|
||||||
Spacer(GlanceModifier.height(4.dp))
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
when (data) {
|
when (data) {
|
||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||||
is AgendaWidgetData.Ready -> {
|
is AgendaWidgetData.Ready -> {
|
||||||
// Range read reactively from per-instance Glance state (falls back
|
// Range read reactively from per-instance Glance state (falls back
|
||||||
// to the saved pref for a freshly placed widget), then the wide
|
// to the saved pref for a freshly placed widget), then the wide
|
||||||
@@ -179,7 +209,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
enabled = showToday,
|
enabled = showToday,
|
||||||
)
|
)
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
visibleDays.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
@@ -191,11 +221,15 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Bound the payload, then drop a day header the cut left
|
||||||
|
// stranded with nothing under it.
|
||||||
|
.take(MAX_AGENDA_ROWS)
|
||||||
|
.dropLastWhile { it is AgendaRow.Header }
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
is AgendaRow.Event -> EventRow(
|
is AgendaRow.Event -> EventRow(
|
||||||
event = row.event,
|
event = row.event,
|
||||||
day = row.date,
|
day = row.date,
|
||||||
@@ -204,6 +238,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
is24Hour = data.is24Hour,
|
is24Hour = data.is24Hour,
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
row.event.hasEnded(data.now),
|
row.event.hasEnded(data.now),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +250,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaHeader() {
|
private fun AgendaHeader(metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
@@ -227,7 +262,7 @@ private fun AgendaHeader() {
|
|||||||
text = context.getString(R.string.widget_agenda_title),
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = GlanceTheme.colors.primary,
|
color = GlanceTheme.colors.primary,
|
||||||
fontSize = 16.sp,
|
fontSize = metrics.title,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -240,6 +275,7 @@ private fun AgendaHeader() {
|
|||||||
resId = R.drawable.ic_widget_refresh,
|
resId = R.drawable.ic_widget_refresh,
|
||||||
contentDescription = context.getString(R.string.widget_refresh),
|
contentDescription = context.getString(R.string.widget_refresh),
|
||||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
resId = R.drawable.ic_widget_add,
|
resId = R.drawable.ic_widget_add,
|
||||||
@@ -249,39 +285,50 @@ private fun AgendaHeader() {
|
|||||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
private fun IconButton(
|
||||||
|
resId: Int,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: GlanceModifier,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(resId),
|
provider = ImageProvider(resId),
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
modifier = GlanceModifier.size(22.dp),
|
modifier = GlanceModifier.size(metrics.iconImage),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Text(
|
Text(
|
||||||
text = agendaDayLabel(context, date, today),
|
text = agendaDayLabel(context, date, today),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = if (date == today) GlanceTheme.colors.primary
|
color = if (date == today) GlanceTheme.colors.primary
|
||||||
else GlanceTheme.colors.onSurfaceVariant,
|
else GlanceTheme.colors.onSurfaceVariant,
|
||||||
fontSize = 13.sp,
|
fontSize = metrics.dayHeader,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
.padding(
|
||||||
|
start = 8.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
top = metrics.dayHeaderTopPad,
|
||||||
|
bottom = metrics.rowVPad,
|
||||||
|
)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||||
@@ -296,14 +343,19 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaceholderRow(date: LocalDate) {
|
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.agenda_no_more_today),
|
text = context.getString(R.string.agenda_no_more_today),
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
|
.padding(
|
||||||
|
start = TEXT_INDENT,
|
||||||
|
end = 8.dp,
|
||||||
|
top = 2.dp,
|
||||||
|
bottom = metrics.rowVPad + 2.dp,
|
||||||
|
)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||||
@@ -320,6 +372,7 @@ private fun EventRow(
|
|||||||
soften: Boolean,
|
soften: Boolean,
|
||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
dimmed: Boolean,
|
dimmed: Boolean,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
) {
|
) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
@@ -332,7 +385,7 @@ private fun EventRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openEventIntent(
|
MainActivity.openEventIntent(
|
||||||
@@ -348,29 +401,29 @@ private fun EventRow(
|
|||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(5.dp)
|
.width(STRIPE_WIDTH)
|
||||||
.height(36.dp)
|
.height(metrics.stripeH)
|
||||||
.cornerRadius(3.dp)
|
.cornerRadius(3.dp)
|
||||||
.background(stripeColor),
|
.background(stripeColor),
|
||||||
) {}
|
) {}
|
||||||
Spacer(GlanceModifier.width(10.dp))
|
Spacer(GlanceModifier.width(STRIPE_GAP))
|
||||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, day, is24Hour),
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WidgetMessage(resId: Int) {
|
private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
@@ -378,7 +431,7 @@ private fun WidgetMessage(resId: Int) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(resId),
|
text = context.getString(resId),
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Calendula</string>
|
<string name="app_name">Calendula</string>
|
||||||
<string name="app_tagline">A modern calendar.</string>
|
<string name="app_tagline">A modern calendar.</string>
|
||||||
|
<!-- Alternative launcher label offered by the "App name" setting (issue #44).
|
||||||
|
translatable="false": this string becomes a launcher component's label, so
|
||||||
|
it must never be changed by an unreviewed community translation. -->
|
||||||
|
<string name="app_name_calendar_alias" translatable="false">Calendar</string>
|
||||||
|
|
||||||
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
||||||
<string name="state_loading">Loading…</string>
|
<string name="state_loading">Loading…</string>
|
||||||
@@ -106,7 +110,6 @@
|
|||||||
<string name="event_edit_timezone_recent">Recent</string>
|
<string name="event_edit_timezone_recent">Recent</string>
|
||||||
<string name="event_edit_timezone_all">All time zones</string>
|
<string name="event_edit_timezone_all">All time zones</string>
|
||||||
<string name="event_edit_timezone_none">No time zone matches “%1$s”</string>
|
<string name="event_edit_timezone_none">No time zone matches “%1$s”</string>
|
||||||
<string name="event_edit_timezone_clear">Use device zone</string>
|
|
||||||
<!-- Shown under the time fields when the event is pinned to another zone:
|
<!-- Shown under the time fields when the event is pinned to another zone:
|
||||||
the same event expressed where the user actually is. %1$s is a time
|
the same event expressed where the user actually is. %1$s is a time
|
||||||
range, e.g. "2:00 PM – 3:00 PM". -->
|
range, e.g. "2:00 PM – 3:00 PM". -->
|
||||||
@@ -332,6 +335,8 @@
|
|||||||
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||||
<string name="settings_today_toolbar">Today button in toolbar</string>
|
<string name="settings_today_toolbar">Today button in toolbar</string>
|
||||||
<string name="settings_today_toolbar_summary">Show a jump-to-today button in the toolbar instead of a floating button</string>
|
<string name="settings_today_toolbar_summary">Show a jump-to-today button in the toolbar instead of a floating button</string>
|
||||||
|
<string name="settings_app_name">App name</string>
|
||||||
|
<string name="settings_app_name_summary">Show Calendula as “Calendar” in your launcher. Only the launcher name changes; the icon may move to a new spot after switching.</string>
|
||||||
<string name="settings_time_format">Time format</string>
|
<string name="settings_time_format">Time format</string>
|
||||||
<string name="settings_time_format_auto">Automatic</string>
|
<string name="settings_time_format_auto">Automatic</string>
|
||||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.appname
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision logic behind the app-name toggle (issue #44). The framework seam
|
||||||
|
* ([LauncherNameManager.set]/[current], which call `PackageManager`) is covered
|
||||||
|
* on-device; these tests pin the read interpretation and the write ordering.
|
||||||
|
*/
|
||||||
|
class LauncherNameManagerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled calendar alias reads as CALENDAR`() {
|
||||||
|
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED))
|
||||||
|
.isEqualTo(LauncherName.CALENDAR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default and disabled calendar alias read as CALENDULA`() {
|
||||||
|
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT))
|
||||||
|
.isEqualTo(LauncherName.CALENDULA)
|
||||||
|
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED))
|
||||||
|
.isEqualTo(LauncherName.CALENDULA)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `write plan enables the target alias before disabling the other`() {
|
||||||
|
val toCalendar = aliasWritePlan(LauncherName.CALENDAR)
|
||||||
|
assertThat(toCalendar).containsExactly(
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||||
|
).inOrder()
|
||||||
|
|
||||||
|
val toCalendula = aliasWritePlan(LauncherName.CALENDULA)
|
||||||
|
assertThat(toCalendula).containsExactly(
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `write plan never disables both aliases in the same step`() {
|
||||||
|
// The first step is always an enable, so the launcher can never observe a
|
||||||
|
// zero-entry transient regardless of switch direction.
|
||||||
|
for (target in LauncherName.entries) {
|
||||||
|
assertThat(aliasWritePlan(target).first().enabled).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,8 +123,18 @@ class EventWriteMapperTest {
|
|||||||
|
|
||||||
private val seriesStart = 1_700_000_000_000L
|
private val seriesStart = 1_700_000_000_000L
|
||||||
|
|
||||||
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
|
private fun update(
|
||||||
buildEventUpdateValues(original, updated, seriesStart, berlin)
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
series: Long = seriesStart,
|
||||||
|
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
|
||||||
|
|
||||||
|
/** The instant [local] names in [zoneId], as the provider would store it. */
|
||||||
|
private fun instantAt(local: String, zoneId: String): Long =
|
||||||
|
java.time.LocalDateTime.parse(local)
|
||||||
|
.atZone(java.time.ZoneId.of(zoneId))
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `pristine form produces no values`() {
|
fun `pristine form produces no values`() {
|
||||||
@@ -219,6 +229,77 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pinning a recurring event to another zone keeps the series wall clock`() {
|
||||||
|
// The regression this guards: the series DTSTART used to move by a
|
||||||
|
// millisecond delta measured at the *edited occurrence*. Here the series
|
||||||
|
// anchor sits in January (Berlin CET, +1) and the edited occurrence in
|
||||||
|
// July (Berlin CEST, +2), so the July delta is an hour off for January —
|
||||||
|
// the whole series would have drifted to 10:00 Tokyo.
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
|
||||||
|
val values = update(original, original.copy(timezone = "Asia/Tokyo"), series)
|
||||||
|
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Asia/Tokyo")
|
||||||
|
// The anchor still reads 09:00 — now 09:00 in Tokyo, not 10:00.
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART])
|
||||||
|
.isEqualTo(instantAt("2026-01-07T09:00", "Asia/Tokyo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a time edit moves the series anchor in wall clock across a DST boundary`() {
|
||||||
|
// Anchor in winter, edited occurrence in summer: pushing the occurrence
|
||||||
|
// one hour later must leave the anchor at 10:00 winter time, not at an
|
||||||
|
// instant that re-reads as 11:00 once the offset differs.
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||||
|
.isEqualTo(instantAt("2026-01-07T10:00", "Europe/Berlin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moving a recurring occurrence to another day shifts the anchor by whole days`() {
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||||
|
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
|
||||||
|
val values = update(original, original.copy(isAllDay = true), series)
|
||||||
|
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||||
|
val dtStart = values[CalendarContract.Events.DTSTART] as Long
|
||||||
|
assertThat(dtStart % (24L * 60 * 60 * 1000)).isEqualTo(0L)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
||||||
val original = form()
|
val original = form()
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class WidgetScaleTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the on-device calibration points map to their tiers`() {
|
||||||
|
// The two sizes measured on-device: the compact widget stays COMPACT (the
|
||||||
|
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
|
||||||
|
// which read as too big on a phone (#51).
|
||||||
|
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a full-width widget at ordinary height still scales up`() {
|
||||||
|
// The regression the height cap used to cause: widening the widget without
|
||||||
|
// also making it unusually tall is *the* resize #51 reports, and it must
|
||||||
|
// reach the tier its width earned. Three cells tall is about 270dp.
|
||||||
|
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `width buckets into the four tiers`() {
|
||||||
|
// Tall enough that the height cap never binds, isolating the width rule.
|
||||||
|
val h = 500.dp
|
||||||
|
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||||
|
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extra height never raises the tier`() {
|
||||||
|
// Height decides how many rows are visible, not how big they are: a tall,
|
||||||
|
// narrow widget wants more events, not bigger text.
|
||||||
|
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only a genuinely squashed widget is stepped back down`() {
|
||||||
|
// Same (wide) width, shrinking height. The cap exists to stop oversized type
|
||||||
|
// surviving in a one- or two-row sliver — it must not fire at normal heights.
|
||||||
|
// Heights are gross; the cap works on height minus 60dp of chrome, so the
|
||||||
|
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
|
||||||
|
val wide = 378.dp
|
||||||
|
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
|
||||||
|
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the tier is monotonic in both axes`() {
|
||||||
|
// Growing a widget must never make its type smaller. Sweeps the whole
|
||||||
|
// plausible range rather than spot-checking, so a future threshold edit
|
||||||
|
// can't accidentally invert a step.
|
||||||
|
val widths = (110..900 step 7).map { it.dp }
|
||||||
|
val heights = (110..900 step 7).map { it.dp }
|
||||||
|
widths.forEach { w ->
|
||||||
|
heights.zipWithNext { shorter, taller ->
|
||||||
|
assertThat(scaleFor(DpSize(w, taller)))
|
||||||
|
.isAtLeast(scaleFor(DpSize(w, shorter)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heights.forEach { h ->
|
||||||
|
widths.zipWithNext { narrower, wider ->
|
||||||
|
assertThat(scaleFor(DpSize(wider, h)))
|
||||||
|
.isAtLeast(scaleFor(DpSize(narrower, h)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||||
|
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AgendaScaleTest {
|
||||||
|
|
||||||
|
// --- the "default size is unchanged" regression guard ---------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every default placement width stays COMPACT`() {
|
||||||
|
// Ties the guarantee to the provider XML's declared 3-cell default rather
|
||||||
|
// than to one measured launcher: the whole band a default placement can
|
||||||
|
// land in must bucket to COMPACT, or a freshly placed widget silently
|
||||||
|
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
|
||||||
|
val band = AGENDA_DEFAULT_WIDTH_BAND
|
||||||
|
var w = band.start
|
||||||
|
while (w <= band.endInclusive) {
|
||||||
|
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
w += 1.dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `COMPACT metrics equal the widget's original constants`() {
|
||||||
|
// If this fails, a default-sized agenda widget no longer looks as it did.
|
||||||
|
val m = metricsFor(WidgetScale.COMPACT)
|
||||||
|
assertThat(m.title).isEqualTo(16.sp)
|
||||||
|
assertThat(m.dayHeader).isEqualTo(13.sp)
|
||||||
|
assertThat(m.eventTitle).isEqualTo(14.sp)
|
||||||
|
assertThat(m.eventTime).isEqualTo(12.sp)
|
||||||
|
assertThat(m.placeholder).isEqualTo(14.sp)
|
||||||
|
assertThat(m.message).isEqualTo(14.sp)
|
||||||
|
assertThat(m.stripeH).isEqualTo(36.dp)
|
||||||
|
assertThat(m.iconImage).isEqualTo(22.dp)
|
||||||
|
assertThat(m.iconBox).isEqualTo(40.dp)
|
||||||
|
assertThat(m.rowVPad).isEqualTo(4.dp)
|
||||||
|
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the derived text indent matches the original hardcoded inset`() {
|
||||||
|
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
|
||||||
|
// day headers and the placeholder line stop aligning with event titles.
|
||||||
|
assertThat(TEXT_INDENT).isEqualTo(19.dp)
|
||||||
|
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the ramp ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sizes are non-decreasing across the tiers`() {
|
||||||
|
val tiers = WidgetScale.entries.map(::metricsFor)
|
||||||
|
|
||||||
|
tiers.zipWithNext { small, big ->
|
||||||
|
assertThat(big.title.value).isAtLeast(small.title.value)
|
||||||
|
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
|
||||||
|
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||||
|
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
|
||||||
|
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
|
||||||
|
assertThat(big.message.value).isAtLeast(small.message.value)
|
||||||
|
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
|
||||||
|
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
||||||
|
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
||||||
|
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
|
||||||
|
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the event title keeps its lead over the time line`() {
|
||||||
|
// The secondary line steps more slowly on purpose; if it ever caught up the
|
||||||
|
// row would lose its hierarchy.
|
||||||
|
WidgetScale.entries.map(::metricsFor).forEach { m ->
|
||||||
|
assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no tier grows type more than half again over the baseline`() {
|
||||||
|
// Guards against a future edit turning "more readable" into "absurd".
|
||||||
|
val base = metricsFor(WidgetScale.COMPACT)
|
||||||
|
val top = metricsFor(WidgetScale.XLARGE)
|
||||||
|
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
|
||||||
|
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- font scale ----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the stripe tracks the system font scale`() {
|
||||||
|
// The stripe is Dp, the text beside it is sp: without this the two diverge
|
||||||
|
// at large accessibility font settings and the stripe under-runs the row.
|
||||||
|
val m = metricsFor(WidgetScale.COMPACT)
|
||||||
|
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
|
||||||
|
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
|
||||||
|
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scaling for the default font scale changes nothing`() {
|
||||||
|
val m = metricsFor(WidgetScale.LARGE)
|
||||||
|
assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `font scaling leaves the sp sizes alone`() {
|
||||||
|
// Glance already applies the font scale to sp; scaling them here too would
|
||||||
|
// double-count it.
|
||||||
|
val m = metricsFor(WidgetScale.REGULAR)
|
||||||
|
val scaled = m.scaledForFont(1.3f)
|
||||||
|
assertThat(scaled.title).isEqualTo(m.title)
|
||||||
|
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
|
||||||
|
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
setting (Settings → Appearance, off by default) swaps the floating corner
|
setting (Settings → Appearance, off by default) swaps the floating corner
|
||||||
button for a permanent today icon in the top bar — always there, matching the
|
button for a permanent today icon in the top bar — always there, matching the
|
||||||
familiar calendar-app pattern. Leave it off to keep the floating button ([#60]).
|
familiar calendar-app pattern. Leave it off to keep the floating button ([#60]).
|
||||||
|
- Show the app as "Calendar" in your launcher. A new App name setting
|
||||||
|
(Settings → Appearance) switches the launcher label from "Calendula" to the
|
||||||
|
generic "Calendar" for anyone who prefers it — handy on launchers that can't
|
||||||
|
rename apps themselves. Only the launcher name changes; your home-screen icon
|
||||||
|
may move to a new spot after switching ([#44]).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Dates in the Month, Week and Day title bars now follow your language and
|
- Dates in the Month, Week and Day title bars now follow your language and
|
||||||
|
|||||||
Reference in New Issue
Block a user