From 03f6b7c8c1f72b94532eafa51c7d2c94ca62ae16 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:39:08 +0200 Subject: [PATCH 1/7] feat(settings): optional "Calendar" launcher name (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Settings → Appearance toggle that switches the app's launcher label between "Calendula" and "Calendar", for users on launchers that can't rename apps themselves. The launcher entry moves off MainActivity onto two components (DefaultNameAlias / CalendarNameAlias); exactly one is enabled at a time via PackageManager.setComponentEnabledSetting. MainActivity keeps every other intent filter; the android.app.shortcuts meta-data moves onto both aliases so the long-press shortcut still publishes. Component-enabled state is the single source of truth — no persisted preference. The ComponentName uses the applicationId for the package (carrying the .debug/.releasetest suffix) and the namespace for the class, since manifest ".Alias" names resolve against the namespace; switching to Calendar enables the target alias before disabling the other to avoid a zero-entry launcher transient. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 47 +++++++- .../data/appname/LauncherNameManager.kt | 109 ++++++++++++++++++ .../calendula/ui/settings/SettingsScreen.kt | 32 +++++ .../ui/settings/SettingsViewModel.kt | 22 ++++ app/src/main/res/values/strings.xml | 6 + .../data/appname/LauncherNameManagerTest.kt | 51 ++++++++ .../android/en-US/changelogs/21600.txt | 5 + 7 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManager.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt 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… @@ -329,6 +333,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/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 From 91c4a84818f5f1d344d1183c709f99e824a131ae Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:57:28 +0200 Subject: [PATCH 2/7] refactor(settings): app-name as a full-screen chooser, not a switch Replace the inline App name switch with a GroupedRow that opens a full-screen OptionPicker (Calendula / Calendar), matching the app's other "choose one" settings and the ReFra pattern the user preferred. The row shows the current name; the picker's header carries the explanatory + icon-may-move hint. Leaves room for more launcher names later without redesigning the row. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index e5f26fb..84babc8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -488,6 +488,7 @@ private fun AppearanceScreen( var showPastEvents by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) } var showPlainFont by remember { mutableStateOf(false) } + var showAppName by remember { mutableStateOf(false) } val fonts by viewModel.fontState.collectAsStateWithLifecycle() val launcherName by viewModel.launcherName.collectAsStateWithLifecycle() @@ -684,35 +685,31 @@ private fun AppearanceScreen( Spacer(Modifier.height(16.dp)) - // App name — flips the launcher label between "Calendula" and "Calendar" + // App name — chooses the launcher label between "Calendula" and "Calendar" // (issue #44). Own group: it's a launcher/system concern, not calendar - // formatting. + // 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 = stringResource(R.string.settings_app_name_summary), + summary = launcherNameLabel(launcherName), position = Position.Alone, - trailing = { - Switch( - checked = launcherName == LauncherName.CALENDAR, - onCheckedChange = { - viewModel.setLauncherName( - if (it) LauncherName.CALENDAR else LauncherName.CALENDULA, - ) - }, - ) - }, - onClick = { - viewModel.setLauncherName( - if (launcherName == LauncherName.CALENDAR) { - LauncherName.CALENDULA - } else { - LauncherName.CALENDAR - }, - ) - }, + onClick = { showAppName = true }, ) } + if (showAppName) { + OptionPicker( + title = stringResource(R.string.settings_app_name), + predictiveBack = true, + options = LauncherName.entries, + selected = launcherName, + label = { launcherNameLabel(it) }, + onSelect = viewModel::setLauncherName, + onDismiss = { showAppName = false }, + // Explain what changes (and the icon-may-move caveat) above the choices. + header = { SettingsHint(stringResource(R.string.settings_app_name_summary)) }, + ) + } if (showTheme) { OptionPicker( title = stringResource(R.string.settings_theme), @@ -1736,6 +1733,15 @@ private fun openUrl(context: Context, url: String) { 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 + }, +) + @Composable private fun themeLabel(mode: ThemeMode): String = stringResource( when (mode) { From 3ffe76412eee2e37fcc4e31571656bdccb4c9d2d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 17:05:14 +0200 Subject: [PATCH 3/7] feat(settings): launcher-mark hero on the App name picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a preview hero to the App name picker header: the app's launcher mark over the current name on a tonal surface card, the explanatory hint below it, then generous space before the choices. Gives the picker the visual "display" the inline hint lacked and separates the text from the options. Follows the material-3 guidance — tonal surfaceContainerHigh card (no shadow), 28dp corner, 8dp-grid spacing, onSurface/onSurfaceVariant role pairing — and reuses the onboarding BrandHero's squircle reconstruction of the adaptive icon so it renders identically everywhere. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 84babc8..d7d2e12 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -706,8 +706,10 @@ private fun AppearanceScreen( label = { launcherNameLabel(it) }, onSelect = viewModel::setLauncherName, onDismiss = { showAppName = false }, - // Explain what changes (and the icon-may-move caveat) above the choices. - header = { SettingsHint(stringResource(R.string.settings_app_name_summary)) }, + // A launcher-mark hero shows how the name reads under the icon, with + // the explanatory + icon-may-move hint, then breathing room above the + // choices. + header = { AppNamePreviewHero(launcherName) }, ) } if (showTheme) { @@ -1742,6 +1744,64 @@ private fun launcherNameLabel(name: LauncherName): String = stringResource( }, ) +/** + * Header for the App name picker (issue #44): the app's launcher mark over the + * current name — a small preview of how the home screen reads — with the + * explanatory hint below, then space before the choices. The icon never changes + * (only the label does), so the mark is constant across the options. + */ +@Composable +private fun AppNamePreviewHero(name: LauncherName) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(vertical = 28.dp, horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // The adaptive launcher mark, reconstructed as a squircle (as in + // the onboarding BrandHero) so it renders identically everywhere. + Box( + modifier = Modifier + .size(88.dp) + .clip(RoundedCornerShape(24.dp)) + .background(colorResource(R.color.ic_launcher_background)), + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + Spacer(Modifier.height(16.dp)) + Text( + text = launcherNameLabel(name), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.settings_app_name_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp), + ) + Spacer(Modifier.height(24.dp)) + } +} + @Composable private fun themeLabel(mode: ThemeMode): String = stringResource( when (mode) { From 7d357bad87ea16e882685dfe59b72a25f7a3ed82 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 17:09:10 +0200 Subject: [PATCH 4/7] feat(settings): App name picker shows both names as preview cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single current-state hero + option rows with two selectable launcher-mark cards (Calendula / Calendar), so the user sees what each choice would look like, not just the current name. Tapping a card applies immediately and highlights it (primary border + tinted container + check); the picker stays open so the change is visible, and back exits — which also fixes the earlier "hero doesn't update until reopened" gap. Built on FullScreenPicker directly (the component OptionPicker wraps) to render the custom card row; material-3 tokens throughout (surfaceContainerHigh / primary / primaryContainer / outlineVariant, 24dp corners, 8dp-grid spacing). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 162 ++++++++++++------ 1 file changed, 106 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index d7d2e12..3f4788c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -23,6 +23,8 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Image 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.Box import androidx.compose.foundation.layout.Column @@ -75,6 +77,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource @@ -698,19 +701,41 @@ private fun AppearanceScreen( } if (showAppName) { - OptionPicker( + FullScreenPicker( title = stringResource(R.string.settings_app_name), - predictiveBack = true, - options = LauncherName.entries, - selected = launcherName, - label = { launcherNameLabel(it) }, - onSelect = viewModel::setLauncherName, onDismiss = { showAppName = false }, - // A launcher-mark hero shows how the name reads under the icon, with - // the explanatory + icon-may-move hint, then breathing room above the - // choices. - header = { AppNamePreviewHero(launcherName) }, - ) + 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) { OptionPicker( @@ -1745,60 +1770,85 @@ private fun launcherNameLabel(name: LauncherName): String = stringResource( ) /** - * Header for the App name picker (issue #44): the app's launcher mark over the - * current name — a small preview of how the home screen reads — with the - * explanatory hint below, then space before the choices. The icon never changes - * (only the label does), so the mark is constant across the options. + * 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 AppNamePreviewHero(name: LauncherName) { +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 - .fillMaxWidth() - .padding(horizontal = 16.dp), + 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), ) { - Surface( - shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth(), + // 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)), ) { - Column( - modifier = Modifier.padding(vertical = 28.dp, horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // The adaptive launcher mark, reconstructed as a squircle (as in - // the onboarding BrandHero) so it renders identically everywhere. - Box( - modifier = Modifier - .size(88.dp) - .clip(RoundedCornerShape(24.dp)) - .background(colorResource(R.color.ic_launcher_background)), - ) { - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - ) - } - Spacer(Modifier.height(16.dp)) - Text( - text = launcherNameLabel(name), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, + 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), ) } } - Spacer(Modifier.height(16.dp)) - Text( - text = stringResource(R.string.settings_app_name_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 8.dp), - ) - Spacer(Modifier.height(24.dp)) } } From 30fcbfa59fcba2a99d5a2499562c37864fe782d5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:22:07 +0200 Subject: [PATCH 5/7] fix(widget): scale the agenda widget with its size (#51) The "Upcoming" agenda widget used Glance's default SizeMode.Single: it was composed once at the minimum size and the launcher stretched that single RemoteViews when enlarged, so the text stayed small-widget-sized no matter how big the widget grew. Reported as a "font size" request (#51), but it's really a missing size-response. Switch to SizeMode.Exact (like MonthWidget) and read LocalSize.current to pick one of four tiers (COMPACT/REGULAR/LARGE/XLARGE), scaling type and row metrics. Exact over Responsive so the ~30-day LazyColumn isn't replicated per tier. Width picks the tier, height can only lower it. Width 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, so a tall narrow widget shows more events rather than bigger text. Height does act as a cap, though, or a squashed widget would keep the large type its width earned in a sliver of space. Thresholds are spread over the width range a phone actually produces (measured on a Pixel/Nova: a compact widget is 222dp wide, a large one 378dp) rather than a theoretical range, so the tiers are reachable in practice; XLARGE is reserved for tablets/foldables. COMPACT reproduces the original constants verbatim, so an existing widget is visually unchanged. The tier logic lives in a pure, Glance-free AgendaScale.kt (compose.ui.unit only) and is JVM-tested: the COMPACT baseline, the width buckets, the height cap stepping a squashed widget down, and that height never raises the tier. No new setting: the widget follows the size the launcher/user already chose. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 ++ .../calendula/widget/agenda/AgendaScale.kt | 129 ++++++++++++++++++ .../calendula/widget/agenda/AgendaWidget.kt | 62 ++++++--- .../widget/agenda/AgendaScaleTest.kt | 105 ++++++++++++++ 4 files changed, 284 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e901b4..d807ca8 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 @@ -1038,5 +1046,6 @@ 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt new file mode 100644 index 0000000..5e1880d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -0,0 +1,129 @@ +package de.jeanlucmakiola.calendula.widget.agenda + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Size tiers the agenda widget scales its typography and row metrics across (#51). + * + * The widget uses [androidx.glance.appwidget.SizeMode.Exact], so the composition + * sees the widget's live size via `LocalSize.current`; [scaleFor] buckets that + * into one of these tiers and [metricsFor] returns the sizes to draw with. Kept + * in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing and the + * "default size is unchanged" invariant are covered by plain JVM tests. + */ +internal enum class AgendaScale { COMPACT, REGULAR, LARGE, XLARGE } + +/** + * Every size the agenda widget varies by tier. Values that don't need to grow + * (horizontal paddings, the 5.dp stripe width, corner radii) stay inline literals + * in the widget 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 +) + +/** + * [AgendaMetrics] per tier. [AgendaScale.COMPACT] reproduces the widget's original + * hardcoded constants **verbatim** — so at the default/small size nothing changes; + * a JVM test pins this against the baseline. Larger tiers scale up by roughly + * 1.12× / 1.28× / 1.45× (starting points to refine on-device). + */ +internal fun metricsFor(scale: AgendaScale): AgendaMetrics = when (scale) { + AgendaScale.COMPACT -> AgendaMetrics( + title = 16.sp, + dayHeader = 13.sp, + eventTitle = 14.sp, + eventTime = 12.sp, + placeholder = 14.sp, + message = 14.sp, + stripeH = 36.dp, + iconImage = 22.dp, + iconBox = 40.dp, + rowVPad = 4.dp, + ) + AgendaScale.REGULAR -> AgendaMetrics( + title = 18.sp, + dayHeader = 14.sp, + eventTitle = 15.sp, + eventTime = 13.sp, + placeholder = 15.sp, + message = 15.sp, + stripeH = 40.dp, + iconImage = 24.dp, + iconBox = 44.dp, + rowVPad = 5.dp, + ) + AgendaScale.LARGE -> AgendaMetrics( + title = 20.sp, + dayHeader = 16.sp, + eventTitle = 17.sp, + eventTime = 14.sp, + placeholder = 16.sp, + message = 16.sp, + stripeH = 46.dp, + iconImage = 26.dp, + iconBox = 48.dp, + rowVPad = 6.dp, + ) + AgendaScale.XLARGE -> AgendaMetrics( + title = 22.sp, + dayHeader = 18.sp, + eventTitle = 19.sp, + eventTime = 15.sp, + placeholder = 18.sp, + message = 18.sp, + stripeH = 52.dp, + iconImage = 28.dp, + iconBox = 52.dp, + rowVPad = 8.dp, + ) +} + +/** + * Buckets a live widget size into an [AgendaScale] 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 very short widget is stepped back + * down so it can't keep oversized type in a sliver of space. + * + * The thresholds are spread across the width 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 + * large 378dp-wide one reaches LARGE. XLARGE is reserved for genuinely wide + * surfaces — tablets, foldables, landscape — where the extra size reads well. + */ +internal fun scaleFor(size: DpSize): AgendaScale { + val byWidth = when { + size.width < 260.dp -> AgendaScale.COMPACT + size.width < 330.dp -> AgendaScale.REGULAR + size.width < 420.dp -> AgendaScale.LARGE + else -> AgendaScale.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". + val heightCap = when { + size.height < 220.dp -> AgendaScale.COMPACT + size.height < 320.dp -> AgendaScale.REGULAR + size.height < 420.dp -> AgendaScale.LARGE + else -> AgendaScale.XLARGE + } + return minOf(byWidth, heightCap) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 198a21a..c2fe4de 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -5,7 +5,6 @@ import android.content.res.Configuration import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.ColorFilter @@ -14,9 +13,11 @@ import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme import androidx.glance.Image import androidx.glance.ImageProvider +import androidx.glance.LocalSize import androidx.glance.action.ActionParameters import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity @@ -107,6 +108,13 @@ class AgendaWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition + // Exact (not Responsive) so a single copy of the day/event list is laid out at + // the widget's live size — Responsive would replicate the whole LazyColumn per + // declared tier. The composition reads LocalSize.current and scales type/rows + // from it ([scaleFor]/[metricsFor]); at the default size that resolves to + // COMPACT, i.e. the layout is unchanged (#51). + override val sizeMode = SizeMode.Exact + override suspend fun provideGlance(context: Context, id: GlanceId) { val data = context.loadAgendaWidgetData() val dark = (context.resources.configuration.uiMode and @@ -136,16 +144,19 @@ private sealed interface AgendaRow { @Composable 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). + val metrics = metricsFor(scaleFor(LocalSize.current)) Column( modifier = GlanceModifier .fillMaxSize() .background(GlanceTheme.colors.surface) .padding(horizontal = 8.dp, vertical = 6.dp), ) { - AgendaHeader() + AgendaHeader(metrics) Spacer(GlanceModifier.height(4.dp)) when (data) { - AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission) + AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) is AgendaWidgetData.Ready -> { // Range read reactively from per-instance Glance state (falls back // to the saved pref for a freshly placed widget), then the wide @@ -179,7 +190,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { enabled = showToday, ) if (visibleDays.isEmpty()) { - WidgetMessage(R.string.agenda_empty_title) + WidgetMessage(R.string.agenda_empty_title, metrics) } else { val rows = buildList { visibleDays.forEach { day -> @@ -194,8 +205,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(rows.size) { index -> when (val row = rows[index]) { - is AgendaRow.Header -> DayHeaderRow(row.date, row.today) - is AgendaRow.Placeholder -> PlaceholderRow(row.date) + is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics) + is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) is AgendaRow.Event -> EventRow( event = row.event, day = row.date, @@ -204,6 +215,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { is24Hour = data.is24Hour, dimmed = pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now), + metrics = metrics, ) } } @@ -215,7 +227,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { } @Composable -private fun AgendaHeader() { +private fun AgendaHeader(metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Row( modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), @@ -227,7 +239,7 @@ private fun AgendaHeader() { text = context.getString(R.string.widget_agenda_title), style = TextStyle( color = GlanceTheme.colors.primary, - fontSize = 16.sp, + fontSize = metrics.title, fontWeight = FontWeight.Medium, ), modifier = GlanceModifier @@ -240,6 +252,7 @@ private fun AgendaHeader() { resId = R.drawable.ic_widget_refresh, contentDescription = context.getString(R.string.widget_refresh), onClick = GlanceModifier.clickable(actionRunCallback()), + metrics = metrics, ) IconButton( resId = R.drawable.ic_widget_add, @@ -249,34 +262,40 @@ private fun AgendaHeader() { MainActivity.openCreateIntent(context, today(systemZone())), ), ), + metrics = metrics, ) } } @Composable -private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) { +private fun IconButton( + resId: Int, + contentDescription: String, + onClick: GlanceModifier, + metrics: AgendaMetrics, +) { Box( - modifier = GlanceModifier.size(40.dp).then(onClick), + modifier = GlanceModifier.size(metrics.iconBox).then(onClick), contentAlignment = Alignment.Center, ) { Image( provider = ImageProvider(resId), contentDescription = contentDescription, colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(22.dp), + modifier = GlanceModifier.size(metrics.iconImage), ) } } @Composable -private fun DayHeaderRow(date: LocalDate, today: LocalDate) { +private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Text( text = agendaDayLabel(context, date, today), style = TextStyle( color = if (date == today) GlanceTheme.colors.primary else GlanceTheme.colors.onSurfaceVariant, - fontSize = 13.sp, + fontSize = metrics.dayHeader, fontWeight = FontWeight.Medium, ), modifier = GlanceModifier @@ -296,11 +315,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) { * plain too (a stripe + text, not cards), so a card here would look out of place. */ @Composable -private fun PlaceholderRow(date: LocalDate) { +private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Text( 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 .fillMaxWidth() .padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp) @@ -320,6 +339,7 @@ private fun EventRow( soften: Boolean, is24Hour: Boolean, dimmed: Boolean, + metrics: AgendaMetrics, ) { val context = androidx.glance.LocalContext.current val title = event.title.ifBlank { context.getString(R.string.event_untitled) } @@ -332,7 +352,7 @@ private fun EventRow( Row( modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp) + .padding(horizontal = 4.dp, vertical = metrics.rowVPad) .clickable( actionStartActivity( MainActivity.openEventIntent( @@ -349,7 +369,7 @@ private fun EventRow( Box( modifier = GlanceModifier .width(5.dp) - .height(36.dp) + .height(metrics.stripeH) .cornerRadius(3.dp) .background(stripeColor), ) {} @@ -358,19 +378,19 @@ private fun EventRow( Text( text = title, maxLines = 1, - style = TextStyle(color = titleColor, fontSize = 14.sp), + style = TextStyle(color = titleColor, fontSize = metrics.eventTitle), ) Text( text = eventTimeSummary(context, event, day, is24Hour), maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime), ) } } } @Composable -private fun WidgetMessage(resId: Int) { +private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Box( modifier = GlanceModifier.fillMaxSize().padding(16.dp), @@ -378,7 +398,7 @@ private fun WidgetMessage(resId: Int) { ) { Text( text = context.getString(resId), - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message), ) } } 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..6ce534e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -0,0 +1,105 @@ +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 org.junit.jupiter.api.Test + +class AgendaScaleTest { + + // --- scaleFor: bucketing ------------------------------------------------- + + @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 large one steps up to LARGE — not XLARGE, + // which read as too big on a phone (#51). + assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(AgendaScale.COMPACT) + assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(AgendaScale.LARGE) + } + + @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(AgendaScale.COMPACT) + assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(AgendaScale.COMPACT) + assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(AgendaScale.REGULAR) + assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(AgendaScale.REGULAR) + assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(AgendaScale.LARGE) + assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(AgendaScale.LARGE) + assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(AgendaScale.XLARGE) + assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(AgendaScale.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. + val short = scaleFor(DpSize(222.dp, 200.dp)) + val tall = scaleFor(DpSize(222.dp, 900.dp)) + assertThat(short).isEqualTo(AgendaScale.COMPACT) + assertThat(tall).isEqualTo(AgendaScale.COMPACT) + } + + @Test + fun `a squashed widget is stepped back down`() { + // Same (wide) width, shrinking height: the tier must walk down rather than + // keep big type in a sliver of space. + val wide = 378.dp + assertThat(scaleFor(DpSize(wide, 672.dp))).isEqualTo(AgendaScale.LARGE) + assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(AgendaScale.LARGE) + assertThat(scaleFor(DpSize(wide, 300.dp))).isEqualTo(AgendaScale.REGULAR) + assertThat(scaleFor(DpSize(wide, 200.dp))).isEqualTo(AgendaScale.COMPACT) + } + + @Test + fun `the height cap never exceeds what the width earned`() { + // A tall but narrow widget stays at its width's tier — the cap only ever + // lowers, so a huge height can't promote a 222dp-wide widget. + assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(AgendaScale.COMPACT) + assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(AgendaScale.REGULAR) + } + + // --- metricsFor: the "default unchanged" regression guard ----------------- + + @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(AgendaScale.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) + } + + @Test + fun `type sizes are non-decreasing across the tiers`() { + val tiers = listOf( + AgendaScale.COMPACT, + AgendaScale.REGULAR, + AgendaScale.LARGE, + AgendaScale.XLARGE, + ).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) + } + } +} From 94355bf340cd259000a0558bdbebbc4e7c63e137 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 13:10:39 +0200 Subject: [PATCH 6/7] fix: address code-review findings on the 2.16.0 branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - Recurring writes: move the series DTSTART by the *wall-clock* shift applied to the edited occurrence and re-resolve it in the event's zone, instead of by a millisecond delta. The old delta baked in whichever UTC offset applied on the edited occurrence's date, so pinning a recurring event to another zone — or editing an occurrence on the far side of a DST boundary from the series anchor — shifted the whole series by an hour. Also snaps the anchor to a UTC midnight when the event becomes all-day. - Detail card and edit form now resolve a pinned zone's abbreviation/offset at the *event's* instant, not at "now", so a July event no longer reads "CET · 10:00 AM" when opened in January. - Agenda: the zone used to label multi-day rows now travels on AgendaUiState.Success rather than a process-lifetime file-level constant, so labelling can't disagree with the grouping after a device time-zone change. - Week title: spell out the year when the week straddles New Year, via a new forceYear flag on formatCalendarTitle. Performance: - Build the ~600-entry zone catalogue off the main thread (produceState + Dispatchers.Default); resolve the device row's summary on its own so it still renders complete on the first frame. - Pre-normalize each TimeZoneOption's search keys at construction, turning ~2400 NFD normalizations per keystroke into plain prefix/substring checks. Hoist the combining-mark Regex out of the hot path. - Key the edit form's local-time line on the fields it reads instead of recomputing it on every keystroke. - Move LauncherNameManager's PackageManager binder calls off the main thread. Cleanup: - Drop a duplicate Public icon import and the unused event_edit_timezone_clear string. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/EventWriteMapper.kt | 57 +++++++++++-- .../calendula/domain/TimeZoneCatalog.kt | 59 +++++++++---- .../calendula/ui/agenda/AgendaScreen.kt | 35 +++++--- .../calendula/ui/agenda/AgendaUiState.kt | 7 ++ .../calendula/ui/agenda/AgendaViewModel.kt | 1 + .../calendula/ui/common/CalendarTitle.kt | 8 +- .../calendula/ui/common/TimeZonePicker.kt | 37 +++++--- .../calendula/ui/detail/EventDetailScreen.kt | 24 +++++- .../calendula/ui/edit/EventEditScreen.kt | 27 +++++- .../ui/settings/SettingsViewModel.kt | 31 +++++-- .../calendula/ui/week/WeekScreen.kt | 16 +++- app/src/main/res/values/strings.xml | 1 - .../data/calendar/EventWriteMapperTest.kt | 85 ++++++++++++++++++- 13 files changed, 324 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 4053588..8e64db5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventForm import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDateTime +import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset +import java.time.LocalDateTime as JavaLocalDateTime /** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ internal data class EventWriteTimes( @@ -35,7 +37,7 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa timezone = "UTC", ) } else { - val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone + val writeZone = writeZone(zone) EventWriteTimes( dtStartMillis = start.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 * 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): * - unchanged times, all-day flag and rrule → no time columns at all; * - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared; - * - recurring result → the *series* DTSTART moves by the same delta the user - * applied to the displayed occurrence ([seriesDtStartMillis] is the row's - * current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps - * past occurrences intact when someone edits a later occurrence's time. + * - recurring result → the *series* DTSTART moves by the same **wall-clock** + * shift the user applied to the displayed occurrence and is re-resolved in the + * event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION + * 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( original: EventForm, @@ -158,8 +186,23 @@ internal fun buildEventUpdateValues( put(CalendarContract.Events.RRULE, null) put(CalendarContract.Events.DURATION, null) } else { - val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis - put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta) + // Move the series anchor by the *wall-clock* shift the user applied to the + // 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.RRULE, updated.rrule) put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index c107099..d19e483 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -31,8 +31,33 @@ data class TimeZoneOption( /** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */ 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( zone: ZoneId, locale: Locale, @@ -87,9 +112,13 @@ private fun resolveAbbreviation( private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT") /** - * Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it - * once and filter the result rather than rebuilding per keystroke. [regionOf] - * (see [resolveAbbreviation]) supplies the zone's region for the abbreviation. + * Every zone the JVM knows, resolved at [at]. [regionOf] (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 * dropped: they're aliases the tz database keeps for compatibility, they'd @@ -154,18 +183,15 @@ fun filterTimeZones(options: List, query: String): List - val city = option.city.normalizeForSearch() - val id = option.id.normalizeForSearch() - val name = option.displayName.normalizeForSearch() - val abbrev = option.shortName.normalizeForSearch() + val keys = option.searchKeys val rank = when { - city.startsWith(needle) -> 0 - abbrev == needle -> 1 - name.startsWith(needle) -> 2 - abbrev.startsWith(needle) -> 3 - city.contains(needle) -> 4 - id.contains(needle) -> 5 - name.contains(needle) -> 6 + keys.city.startsWith(needle) -> 0 + keys.shortName == needle -> 1 + keys.displayName.startsWith(needle) -> 2 + keys.shortName.startsWith(needle) -> 3 + keys.city.contains(needle) -> 4 + keys.id.contains(needle) -> 5 + keys.displayName.contains(needle) -> 6 else -> return@mapNotNull null } rank to option @@ -174,6 +200,9 @@ fun filterTimeZones(options: List, query: String): List, query: String): List, today: LocalDate, + zone: TimeZone, dimPast: Boolean, now: Instant, onEventClick: (EventInstance) -> Unit, @@ -379,6 +383,7 @@ private fun AgendaList( AgendaEventRow( event = event, day = day.date, + zone = zone, position = positionOf(index, day.events.size), dimmed = dimPast && event.hasEnded(now), modifier = animateItemMotion(), @@ -458,6 +463,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) { private fun AgendaEventRow( event: EventInstance, day: LocalDate, + zone: TimeZone, position: Position, dimmed: Boolean, modifier: Modifier = Modifier, @@ -469,7 +475,7 @@ private fun AgendaEventRow( GroupedRow( modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, title = title, - summary = agendaTimeSummary(event, day), + summary = agendaTimeSummary(event, day, zone), position = position, minHeight = 64.dp, 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. */ @Composable -private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { +private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String { val is24Hour = LocalUse24HourFormat.current val locale = currentLocale() val time = when (val label = agendaTimeLabel(event, day, zone)) { AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) - is AgendaTimeLabel.Starts -> - stringResource(R.string.agenda_span_starts, formatTime(label.start, is24Hour, locale)) - is AgendaTimeLabel.Ends -> - stringResource(R.string.agenda_span_ends, formatTime(label.end, is24Hour, locale)) - is AgendaTimeLabel.Range -> - "${formatTime(label.start, is24Hour, locale)} – ${formatTime(label.end, is24Hour, locale)}" + is AgendaTimeLabel.Starts -> stringResource( + R.string.agenda_span_starts, + formatTime(label.start, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Ends -> stringResource( + 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() } 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 return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index b88b1a7..8bd3be8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -160,5 +160,12 @@ sealed interface AgendaUiState { val rangeEnd: LocalDate, /** Whether to show the top range bar — header + switcher (toggle, on by default). */ 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 924f6b8..d19271e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -172,6 +172,7 @@ class AgendaViewModel @Inject constructor( rangeIsOverride = params.rangeIsOverride, rangeEnd = rangeEnd, showRangeBar = params.showRangeBar, + zone = zone, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt index 3cb3422..8b87359 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt @@ -16,13 +16,19 @@ import java.util.Locale * 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 * 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( date: java.time.LocalDate, locale: Locale, currentYear: Int, skeleton: String, + forceYear: Boolean = false, ): 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index fc5d472..fe06016 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -35,6 +36,7 @@ import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.filterTimeZones +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptions import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.components.FullScreenPicker @@ -43,6 +45,8 @@ import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.positionOf 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 @@ -64,14 +68,28 @@ fun TimeZonePickerDialog( onDismiss: () -> Unit, ) { 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 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(), locale) { + value = withContext(Dispatchers.Default) { + timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion) + } + } val matches = remember(allZones, query) { filterTimeZones(allZones, query) } val recentZones = remember(allZones, recents) { 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() fun choose(zoneId: String?) { @@ -97,7 +115,7 @@ fun TimeZonePickerDialog( item(key = "device") { GroupedRow( title = stringResource(R.string.event_edit_timezone_device), - summary = zoneSummary(deviceZoneId, allZones), + summary = deviceSummary, position = Position.Alone, selected = selected == 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") { Text( text = stringResource(R.string.event_edit_timezone_none, query), @@ -252,9 +273,3 @@ private fun ZoneRow( onClick = onClick, ) } - -/** "CET · GMT+01:00" for [zoneId], or the bare id if the catalogue lacks it. */ -private fun zoneSummary(zoneId: String, allZones: List): String = - allZones.firstOrNull { it.id == zoneId } - ?.let { zoneDescriptor(it) } - ?: zoneId diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 639f7fd..2deeb4e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -109,6 +109,7 @@ import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone +import kotlin.time.toJavaInstant import java.time.ZoneId import java.time.format.DateTimeFormatter 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, // 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. - val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) { - foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale) + val foreignZone = remember(detail.eventTimezone, instance.isAllDay, instance.start, locale) { + foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start) } foreignZone?.let { zoneOption -> 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 * removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the * 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 (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]. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 3f5d920..74ec60b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd 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.Repeat import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune @@ -164,8 +163,10 @@ import kotlinx.datetime.isoDayNumber import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toKotlinDayOfWeek +import kotlinx.datetime.toInstant import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime +import kotlin.time.toJavaInstant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -683,7 +684,13 @@ private fun EventEditContent( // whoever is reading it. Spell the local equivalent out rather than // leaving the user to do the offset arithmetic. Absent (null) unless // 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)) Text( text = stringResource( @@ -741,8 +748,20 @@ private fun EventEditContent( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - val pinned = remember(form.timezone, locale) { - form.timezone?.let { timeZoneOptionOf(it, locale, regionOf = ::icuTimeZoneRegion) } + // Resolved at the event's own start, not at "now": the + // 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)) { Text( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 8b6c58e..de3bcbe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -213,10 +213,22 @@ class SettingsViewModel @Inject constructor( * 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(launcherNameManager.current()) + private val _launcherName = MutableStateFlow(LauncherName.CALENDULA) val launcherName: StateFlow = _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 // surfaces it and the previous selection stays put. private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1) @@ -484,11 +496,20 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) } } - /** Switch the launcher label between "Calendula" and "Calendar" (issue #44). */ + /** + * 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) { - launcherNameManager.set(name) - // Re-read so the UI reflects the actual component state, not an assumption. - _launcherName.value = launcherNameManager.current() + _launcherName.value = name + viewModelScope.launch { + _launcherName.value = withContext(io) { + launcherNameManager.set(name) + launcherNameManager.current() + } + } } fun setPastEventDisplay(mode: PastEventDisplay) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index bd44535..88b9a14 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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 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 - * makes the title depend on nothing but [weekStart], so it cannot drift with the - * direction you paged in from. + * makes the *month* depend on nothing but [weekStart], so it cannot drift with + * 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 = - formatCalendarTitle( +private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String { + val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) + return formatCalendarTitle( date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1), locale = locale, currentYear = currentYear, skeleton = "LLLL", + forceYear = weekEnd.year != weekStart.year, ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b8019e..bfb853d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,7 +110,6 @@ Recent All time zones No time zone matches “%1$s” - Use device zone 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() From a34f29bcf825136177399b9465af31be843972af Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 14:47:37 +0200 Subject: [PATCH 7/7] fix(widget): address review of the agenda size scaling (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 30fcbfa, fixing eight issues found in review: - Cap the agenda row list at 100. SizeMode.Exact asks Glance for one RemoteViews per host size where SizeMode.Single produced exactly one, roughly doubling the payload; with the range reaching AgendaRange.MAX_CUSTOM_DAYS (365) an uncapped list could push past the binder transaction limit and the host would show "Problem loading widget". A trailing day header stranded by the cut is dropped. - Loosen the height cap so it only catches genuinely squashed widgets. It previously required ~320dp of height for LARGE, so widening a widget without also making it unusually tall — the exact resize #51 reports — stayed REGULAR or COMPACT and the feature was near a no-op for it. Thresholds now work on height minus header chrome. - Lock the event stripe to the system font scale. It is a Dp beside sp text, so at large accessibility settings the text outgrew it and it under-ran the row it marks. - Route the day-header and placeholder padding through the metrics table so vertical rhythm holds at the larger tiers, and derive the text indent from the row constants instead of a hardcoded 19dp. - Share the bucketing as widget/WidgetScale.kt so MonthWidget (already SizeMode.Exact) can adopt one rule rather than growing a parallel copy. - Anchor the type ramp to Material 3 type-scale roles per CLAUDE.md, with the two off-scale values marked and justified inline. COMPACT is unchanged, so a default-sized widget still looks exactly as before. - Tie the "default size unchanged" test to the provider XML's declared 3-cell band rather than one measured 222dp point. - Hang the metrics off an ordinal-indexed table so lookup allocates nothing per recomposition. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/widget/WidgetScale.kt | 70 +++++ .../calendula/widget/agenda/AgendaScale.kt | 247 ++++++++++-------- .../calendula/widget/agenda/AgendaWidget.kt | 53 +++- .../calendula/widget/WidgetScaleTest.kt | 89 +++++++ .../widget/agenda/AgendaScaleTest.kt | 136 +++++----- 5 files changed, 412 insertions(+), 183 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt new file mode 100644 index 0000000..9b19179 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt index 5e1880d..1e55303 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -1,129 +1,150 @@ package de.jeanlucmakiola.calendula.widget.agenda import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import de.jeanlucmakiola.calendula.widget.WidgetScale /** - * Size tiers the agenda widget scales its typography and row metrics across (#51). - * - * The widget uses [androidx.glance.appwidget.SizeMode.Exact], so the composition - * sees the widget's live size via `LocalSize.current`; [scaleFor] buckets that - * into one of these tiers and [metricsFor] returns the sizes to draw with. Kept - * in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing and the - * "default size is unchanged" invariant are covered by plain JVM tests. + * 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 enum class AgendaScale { COMPACT, REGULAR, LARGE, XLARGE } +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 /** - * Every size the agenda widget varies by tier. Values that don't need to grow - * (horizontal paddings, the 5.dp stripe width, corner radii) stay inline literals - * in the widget rather than routing through here. + * 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 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, ) -/** - * [AgendaMetrics] per tier. [AgendaScale.COMPACT] reproduces the widget's original - * hardcoded constants **verbatim** — so at the default/small size nothing changes; - * a JVM test pins this against the baseline. Larger tiers scale up by roughly - * 1.12× / 1.28× / 1.45× (starting points to refine on-device). - */ -internal fun metricsFor(scale: AgendaScale): AgendaMetrics = when (scale) { - AgendaScale.COMPACT -> AgendaMetrics( - title = 16.sp, - dayHeader = 13.sp, - eventTitle = 14.sp, - eventTime = 12.sp, - placeholder = 14.sp, - message = 14.sp, - stripeH = 36.dp, - iconImage = 22.dp, - iconBox = 40.dp, - rowVPad = 4.dp, - ) - AgendaScale.REGULAR -> AgendaMetrics( - title = 18.sp, - dayHeader = 14.sp, - eventTitle = 15.sp, - eventTime = 13.sp, - placeholder = 15.sp, - message = 15.sp, - stripeH = 40.dp, - iconImage = 24.dp, - iconBox = 44.dp, - rowVPad = 5.dp, - ) - AgendaScale.LARGE -> AgendaMetrics( - title = 20.sp, - dayHeader = 16.sp, - eventTitle = 17.sp, - eventTime = 14.sp, - placeholder = 16.sp, - message = 16.sp, - stripeH = 46.dp, - iconImage = 26.dp, - iconBox = 48.dp, - rowVPad = 6.dp, - ) - AgendaScale.XLARGE -> AgendaMetrics( - title = 22.sp, - dayHeader = 18.sp, - eventTitle = 19.sp, - eventTime = 15.sp, - placeholder = 18.sp, - message = 18.sp, - stripeH = 52.dp, - iconImage = 28.dp, - iconBox = 52.dp, - rowVPad = 8.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, +) -/** - * Buckets a live widget size into an [AgendaScale] 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 very short widget is stepped back - * down so it can't keep oversized type in a sliver of space. - * - * The thresholds are spread across the width 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 - * large 378dp-wide one reaches LARGE. XLARGE is reserved for genuinely wide - * surfaces — tablets, foldables, landscape — where the extra size reads well. - */ -internal fun scaleFor(size: DpSize): AgendaScale { - val byWidth = when { - size.width < 260.dp -> AgendaScale.COMPACT - size.width < 330.dp -> AgendaScale.REGULAR - size.width < 420.dp -> AgendaScale.LARGE - else -> AgendaScale.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". - val heightCap = when { - size.height < 220.dp -> AgendaScale.COMPACT - size.height < 320.dp -> AgendaScale.REGULAR - size.height < 420.dp -> AgendaScale.LARGE - else -> AgendaScale.XLARGE - } - return minOf(byWidth, heightCap) -} +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] diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index c2fe4de..7a7f7d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -62,6 +62,7 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme +import de.jeanlucmakiola.calendula.widget.scaleFor import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today @@ -108,11 +109,17 @@ class AgendaWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition - // Exact (not Responsive) so a single copy of the day/event list is laid out at - // the widget's live size — Responsive would replicate the whole LazyColumn per - // declared tier. The composition reads LocalSize.current and scales type/rows + // 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). + // 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) { @@ -134,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. */ private sealed interface AgendaRow { data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow @@ -146,7 +162,10 @@ private sealed interface AgendaRow { 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). - val metrics = metricsFor(scaleFor(LocalSize.current)) + // 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( modifier = GlanceModifier .fillMaxSize() @@ -202,6 +221,10 @@ 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()) { items(rows.size) { index -> when (val row = rows[index]) { @@ -300,7 +323,12 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetri ), modifier = GlanceModifier .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( actionStartActivity( MainActivity.openDateIntent(context, date, CalendarView.Agenda), @@ -322,7 +350,12 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), modifier = GlanceModifier .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( actionStartActivity( MainActivity.openDateIntent(context, date, CalendarView.Agenda), @@ -352,7 +385,7 @@ private fun EventRow( Row( modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = metrics.rowVPad) + .padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad) .clickable( actionStartActivity( MainActivity.openEventIntent( @@ -368,12 +401,12 @@ private fun EventRow( ) { Box( modifier = GlanceModifier - .width(5.dp) + .width(STRIPE_WIDTH) .height(metrics.stripeH) .cornerRadius(3.dp) .background(stripeColor), ) {} - Spacer(GlanceModifier.width(10.dp)) + Spacer(GlanceModifier.width(STRIPE_GAP)) Column(modifier = GlanceModifier.defaultWeight()) { Text( text = title, 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 index 6ce534e..edc84c4 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -4,70 +4,32 @@ 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 { - // --- scaleFor: bucketing ------------------------------------------------- + // --- the "default size is unchanged" regression guard --------------------- @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 large one steps up to LARGE — not XLARGE, - // which read as too big on a phone (#51). - assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(AgendaScale.COMPACT) - assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(AgendaScale.LARGE) + 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 `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(AgendaScale.COMPACT) - assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(AgendaScale.COMPACT) - assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(AgendaScale.REGULAR) - assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(AgendaScale.REGULAR) - assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(AgendaScale.LARGE) - assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(AgendaScale.LARGE) - assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(AgendaScale.XLARGE) - assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(AgendaScale.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. - val short = scaleFor(DpSize(222.dp, 200.dp)) - val tall = scaleFor(DpSize(222.dp, 900.dp)) - assertThat(short).isEqualTo(AgendaScale.COMPACT) - assertThat(tall).isEqualTo(AgendaScale.COMPACT) - } - - @Test - fun `a squashed widget is stepped back down`() { - // Same (wide) width, shrinking height: the tier must walk down rather than - // keep big type in a sliver of space. - val wide = 378.dp - assertThat(scaleFor(DpSize(wide, 672.dp))).isEqualTo(AgendaScale.LARGE) - assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(AgendaScale.LARGE) - assertThat(scaleFor(DpSize(wide, 300.dp))).isEqualTo(AgendaScale.REGULAR) - assertThat(scaleFor(DpSize(wide, 200.dp))).isEqualTo(AgendaScale.COMPACT) - } - - @Test - fun `the height cap never exceeds what the width earned`() { - // A tall but narrow widget stays at its width's tier — the cap only ever - // lowers, so a huge height can't promote a 222dp-wide widget. - assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(AgendaScale.COMPACT) - assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(AgendaScale.REGULAR) - } - - // --- metricsFor: the "default unchanged" regression guard ----------------- - @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(AgendaScale.COMPACT) + val m = metricsFor(WidgetScale.COMPACT) assertThat(m.title).isEqualTo(16.sp) assertThat(m.dayHeader).isEqualTo(13.sp) assertThat(m.eventTitle).isEqualTo(14.sp) @@ -78,16 +40,22 @@ class AgendaScaleTest { 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 `type sizes are non-decreasing across the tiers`() { - val tiers = listOf( - AgendaScale.COMPACT, - AgendaScale.REGULAR, - AgendaScale.LARGE, - AgendaScale.XLARGE, - ).map(::metricsFor) + 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) @@ -100,6 +68,54 @@ class AgendaScaleTest { 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) + } }