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/4] 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/4] 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/4] 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/4] 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)) } }