Merge remote-tracking branch 'origin/release/v2.16.0' into feat/recurrence-exdate
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 11m20s

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
This commit is contained in:
2026-07-20 15:24:05 +02:00
24 changed files with 1219 additions and 90 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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
} }

View File

@@ -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,
) )
} }
} }

View File

@@ -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)
} }

View File

@@ -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

View File

@@ -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]. */

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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,
) )
}

View File

@@ -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)
}

View File

@@ -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]

View File

@@ -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),
) )
} }
} }

View File

@@ -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>

View File

@@ -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()
}
}
}

View File

@@ -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()

View File

@@ -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)))
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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