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