diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ce563..d10b846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Added @@ -1047,6 +1055,7 @@ automatically, with zero telemetry and no internet permission. [#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 +[#51]: https://codeberg.org/jlmakiola/calendula/issues/51 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60 [#65]: https://codeberg.org/jlmakiola/calendula/issues/65 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 930ab70..7f73919 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,10 +63,9 @@ android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize"> - - - - + + + + + + + + + - + + + + + + + + + Calendar Loading… @@ -106,7 +110,6 @@ Recent All time zones No time zone matches “%1$s” - Use device zone @@ -332,6 +335,8 @@ Show calendar-week numbers in month view Today button in toolbar Show a jump-to-today button in the toolbar instead of a floating button + App name + Show Calendula as “Calendar” in your launcher. Only the launcher name changes; the icon may move to a new spot after switching. Time format Automatic 12-hour (2:00 PM) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt new file mode 100644 index 0000000..c806dce --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt @@ -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() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index b606a15..596d35b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -123,8 +123,18 @@ class EventWriteMapperTest { private val seriesStart = 1_700_000_000_000L - private fun update(original: EventForm, updated: EventForm): Map = - buildEventUpdateValues(original, updated, seriesStart, berlin) + private fun update( + original: EventForm, + updated: EventForm, + series: Long = seriesStart, + ): Map = 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 fun `pristine form produces no values`() { @@ -219,6 +229,77 @@ class EventWriteMapperTest { 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 fun `adding a recurrence keeps the times and writes rule plus duration`() { val original = form() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt new file mode 100644 index 0000000..287400c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt @@ -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))) + } + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt new file mode 100644 index 0000000..edc84c4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -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) + } +} diff --git a/fastlane/metadata/android/en-US/changelogs/21600.txt b/fastlane/metadata/android/en-US/changelogs/21600.txt index 2cf7d30..b96a9c1 100644 --- a/fastlane/metadata/android/en-US/changelogs/21600.txt +++ b/fastlane/metadata/android/en-US/changelogs/21600.txt @@ -11,6 +11,11 @@ setting (Settings → Appearance, off by default) swaps the floating corner 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]). +- 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 - Dates in the Month, Week and Day title bars now follow your language and