From 4a3da0963be2f1fdd60c0ee9f6b948b2fbcd096f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 12:03:41 +0100 Subject: [PATCH] feat: switchable launcher name, Agendula or Tasks (#104) --- app/src/main/AndroidManifest.xml | 32 ++++++- .../data/appname/LauncherNameManager.kt | 86 +++++++++++++++++++ .../agendula/ui/settings/AppearanceScreen.kt | 33 +++++++ .../agendula/ui/settings/SettingsViewModel.kt | 23 ++++- app/src/main/res/values/strings.xml | 3 + .../data/appname/LauncherNameManagerTest.kt | 31 +++++++ 6 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManager.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManagerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0276b0b..4290d6e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,10 +65,8 @@ android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize"> - - - - + @@ -89,6 +87,32 @@ + + + + + + + + + + + + = when (target) { + LauncherName.TASKS -> listOf( + AliasStateChange(LauncherAlias.TASKS, enabled = true), + AliasStateChange(LauncherAlias.DEFAULT, enabled = false), + ) + LauncherName.AGENDULA -> listOf( + AliasStateChange(LauncherAlias.DEFAULT, enabled = true), + AliasStateChange(LauncherAlias.TASKS, enabled = false), + ) +} + +/** + * Switches the launcher label by enabling one of the two `` + * components. The component state is the only source of truth; nothing is + * stored in prefs. + */ +@Singleton +class LauncherNameManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val packageManager: PackageManager get() = context.packageManager + + // The package is the (suffixed) applicationId, the class is namespace-qualified. + private fun component(alias: LauncherAlias): ComponentName { + val simpleName = when (alias) { + LauncherAlias.DEFAULT -> "DefaultNameAlias" + LauncherAlias.TASKS -> "TasksNameAlias" + } + return ComponentName(context.packageName, "$NAMESPACE.$simpleName") + } + + fun current(): LauncherName = + launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.TASKS))) + + fun set(name: LauncherName) { + for (change in aliasWritePlan(name)) { + val state = if (change.enabled) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + packageManager.setComponentEnabledSetting(component(change.alias), state, PackageManager.DONT_KILL_APP) + } + // Dynamic shortcuts hang off the launcher activity, which just changed. + AppShortcuts.publish(context) + } + + private companion object { + /** `namespace` in app/build.gradle.kts, not the suffixed applicationId. */ + const val NAMESPACE = "de.jeanlucmakiola.agendula" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt index 7c1ce72..0dbbc9f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt @@ -14,13 +14,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.appname.LauncherName import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.agendula.data.prefs.is24Hour import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedHint import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position @@ -38,6 +41,8 @@ internal fun AppearanceScreen( ) { val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S var showTheme by remember { mutableStateOf(false) } + var showAppName by remember { mutableStateOf(false) } + val launcherName by viewModel.launcherName.collectAsStateWithLifecycle() var showTimeFormat by remember { mutableStateOf(false) } var showWeekStart by remember { mutableStateOf(false) } val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current) @@ -90,6 +95,26 @@ internal fun AppearanceScreen( position = Position.Bottom, onClick = { showWeekStart = true }, ) + + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_app_name), + summary = launcherNameLabel(launcherName), + position = Position.Alone, + onClick = { showAppName = true }, + ) + } + + if (showAppName) { + OptionPicker( + title = stringResource(R.string.settings_app_name), + options = LauncherName.entries, + selected = launcherName, + label = { launcherNameLabel(it) }, + header = { GroupedHint(stringResource(R.string.settings_app_name_hint)) }, + onSelect = viewModel::setLauncherName, + onDismiss = { showAppName = false }, + ) } if (showTimeFormat) { @@ -163,6 +188,14 @@ private fun weekStartLabel(day: DayOfWeek?): String = /** 14:00, the sample time "Automatic" is shown with. */ private const val SAMPLE_MINUTE_OF_DAY = 14 * 60 +@Composable +private fun launcherNameLabel(name: LauncherName): String = stringResource( + when (name) { + LauncherName.AGENDULA -> R.string.app_name + LauncherName.TASKS -> R.string.app_name_tasks_alias + }, +) + private fun themeLabel(mode: ThemeMode): Int = when (mode) { ThemeMode.SYSTEM -> R.string.settings_theme_system ThemeMode.LIGHT -> R.string.settings_theme_light diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index 19e4b9a..f2b2c25 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.appname.LauncherName +import de.jeanlucmakiola.agendula.data.appname.LauncherNameManager import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs @@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.time.DayOfWeek import javax.inject.Inject @@ -102,7 +105,8 @@ class SettingsViewModel @Inject constructor( private val oneShotImport: OneShotImport, private val reminderScheduler: ReminderScheduler, private val taskNotifier: TaskNotifier, - @IoDispatcher io: CoroutineDispatcher, + private val launcherNameManager: LauncherNameManager, + @IoDispatcher private val io: CoroutineDispatcher, repository: TasksRepository, ) : ViewModel() { @@ -217,6 +221,23 @@ class SettingsViewModel @Inject constructor( val legacyImportRetrying: StateFlow = retryingLegacyImport + private val _launcherName = MutableStateFlow(LauncherName.AGENDULA) + val launcherName: StateFlow = _launcherName + + init { + viewModelScope.launch { _launcherName.value = withContext(io) { launcherNameManager.current() } } + } + + fun setLauncherName(name: LauncherName) { + _launcherName.value = name + viewModelScope.launch { + _launcherName.value = withContext(io) { + launcherNameManager.set(name) + launcherNameManager.current() + } + } + } + fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a5dfb7..4cab9db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ Agendula + Tasks Untitled task @@ -448,6 +449,8 @@ Follow system Light Dark + App name + Show Agendula as “Tasks” in your launcher. Only the launcher name changes; the icon may move to a new spot after switching. Dynamic colour Use colours from your wallpaper Notifications diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManagerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManagerTest.kt new file mode 100644 index 0000000..ae3d35d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManagerTest.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.agendula.data.appname + +import android.content.pm.PackageManager +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class LauncherNameManagerTest { + + @Test + fun `an enabled Tasks alias reads as Tasks`() { + assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED)).isEqualTo(LauncherName.TASKS) + } + + @Test + fun `a default or disabled Tasks alias reads as Agendula`() { + assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)).isEqualTo(LauncherName.AGENDULA) + assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)).isEqualTo(LauncherName.AGENDULA) + } + + @Test + fun `switching always enables the target before disabling the other`() { + assertThat(aliasWritePlan(LauncherName.TASKS)).containsExactly( + AliasStateChange(LauncherAlias.TASKS, enabled = true), + AliasStateChange(LauncherAlias.DEFAULT, enabled = false), + ).inOrder() + assertThat(aliasWritePlan(LauncherName.AGENDULA)).containsExactly( + AliasStateChange(LauncherAlias.DEFAULT, enabled = true), + AliasStateChange(LauncherAlias.TASKS, enabled = false), + ).inOrder() + } +}