From 5dc1d492952a17a82353608366a360764f7be833 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 14:28:45 +0100 Subject: [PATCH 1/6] Show the task description in reminder notifications (#94) (#108) --- .../agendula/data/reminders/TaskNotifier.kt | 14 +++++++++++- .../data/reminders/ExpandedTextTest.kt | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ExpandedTextTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt index d7f087d..4adc2c5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt @@ -18,6 +18,7 @@ import androidx.core.content.edit import de.jeanlucmakiola.agendula.MainActivity import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.data.prefs.Settings +import de.jeanlucmakiola.agendula.domain.readableDescription import de.jeanlucmakiola.agendula.data.prefs.is24Hour import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver @@ -84,6 +85,7 @@ class TaskNotifier @Inject constructor( ), ) } + val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() } val tapIntent = PendingIntent.getActivity( context, task.taskId.toInt(), @@ -93,7 +95,10 @@ class TaskNotifier @Inject constructor( val notification = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(title) - .apply { if (text != null) setContentText(text) } + .apply { (text ?: description)?.let(::setContentText) } + .apply { + expandedText(text, description)?.let { setStyle(NotificationCompat.BigTextStyle().bigText(it)) } + } .setCategory(NotificationCompat.CATEGORY_REMINDER) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) @@ -240,3 +245,10 @@ private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays, else -> null } } + +/** The expanded body of a due reminder: the due line, then the task's description. */ +internal fun expandedText(dueLine: String?, description: String?): String? = when { + description == null -> null + dueLine == null -> description + else -> "$dueLine\n\n$description" +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ExpandedTextTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ExpandedTextTest.kt new file mode 100644 index 0000000..4ca7d65 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ExpandedTextTest.kt @@ -0,0 +1,22 @@ +package de.jeanlucmakiola.agendula.data.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ExpandedTextTest { + + @Test + fun `no description means no expanded body`() { + assertThat(expandedText("Due today", null)).isNull() + } + + @Test + fun `description alone when there is no due line`() { + assertThat(expandedText(null, "Buy oat milk")).isEqualTo("Buy oat milk") + } + + @Test + fun `due line comes first, separated by a blank line`() { + assertThat(expandedText("Due today", "Buy oat milk")).isEqualTo("Due today\n\nBuy oat milk") + } +} From c1219a434d7ad1f7df7383078e540e4a197eb414 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 14:29:25 +0100 Subject: [PATCH 2/6] Pure black theme for AMOLED screens (#99) (#109) --- .../java/de/jeanlucmakiola/agendula/MainActivity.kt | 13 ++++++------- .../agendula/data/prefs/SettingsPrefs.kt | 13 ++++++++++++- .../agendula/ui/settings/AppearanceScreen.kt | 2 ++ .../de/jeanlucmakiola/agendula/ui/theme/Theme.kt | 2 ++ .../agendula/widget/TaskWidgetConfigActivity.kt | 13 ++++++------- .../de/jeanlucmakiola/agendula/widget/WidgetData.kt | 2 +- app/src/main/res/values/strings.xml | 2 ++ .../agendula/widget/WidgetDataTest.kt | 1 + 8 files changed, 32 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt index 1ac4cd6..f6386e4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt @@ -28,7 +28,6 @@ import de.jeanlucmakiola.agendula.data.di.LaunchHook import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.ui.imports.importIntentUri import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts -import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.ui.RootScreen import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.agendula.ui.navigation.NavRequest @@ -88,15 +87,15 @@ class MainActivity : ComponentActivity() { setContent { val settingsViewModel: SettingsViewModel = hiltViewModel() val ui by settingsViewModel.state.collectAsStateWithLifecycle() - val darkTheme = when (ui.settings.themeMode) { - ThemeMode.SYSTEM -> isSystemInDarkTheme() - ThemeMode.LIGHT -> false - ThemeMode.DARK -> true - } + val darkTheme = ui.settings.themeMode.isDark(isSystemInDarkTheme()) val context = LocalContext.current val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context)) val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek() - AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) { + AgendulaTheme( + darkTheme = darkTheme, + dynamicColor = ui.settings.dynamicColor, + pureBlack = ui.settings.themeMode.isPureBlack, + ) { CompositionLocalProvider( LocalUse24HourFormat provides use24Hour, LocalFirstDayOfWeek provides firstDayOfWeek, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 15d6244..73f09c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -31,7 +31,18 @@ import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton -enum class ThemeMode { SYSTEM, LIGHT, DARK } +/** [BLACK] is dark with pure-black surfaces, for AMOLED screens. */ +enum class ThemeMode { + SYSTEM, LIGHT, DARK, BLACK; + + fun isDark(systemDark: Boolean): Boolean = when (this) { + SYSTEM -> systemDark + LIGHT -> false + DARK, BLACK -> true + } + + val isPureBlack: Boolean get() = this == BLACK +} /** Clock convention: AUTO follows the device's 24-hour switch. */ enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR } 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..acaa3f9 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 @@ -140,6 +140,7 @@ internal fun AppearanceScreen( options = ThemeMode.entries, selected = state.settings.themeMode, label = { stringResource(themeLabel(it)) }, + summary = { if (it == ThemeMode.BLACK) stringResource(R.string.settings_theme_black_summary) else null }, onSelect = viewModel::setThemeMode, onDismiss = { showTheme = false }, ) @@ -167,4 +168,5 @@ private fun themeLabel(mode: ThemeMode): Int = when (mode) { ThemeMode.SYSTEM -> R.string.settings_theme_system ThemeMode.LIGHT -> R.string.settings_theme_light ThemeMode.DARK -> R.string.settings_theme_dark + ThemeMode.BLACK -> R.string.settings_theme_black } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/Theme.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/Theme.kt index 2bca6f8..6e02f5f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/Theme.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/theme/Theme.kt @@ -15,6 +15,7 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme fun AgendulaTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + pureBlack: Boolean = false, content: @Composable () -> Unit, ) { FloretExpressiveTheme( @@ -22,6 +23,7 @@ fun AgendulaTheme( darkScheme = AgendulaDarkFallback, darkTheme = darkTheme, dynamicColor = dynamicColor, + pureBlack = pureBlack, typography = AgendulaTypography, content = content, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt index 7287add..504720c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt @@ -39,7 +39,6 @@ import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.SmartList @@ -135,12 +134,12 @@ class TaskWidgetConfigActivity : ComponentActivity() { rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY]) selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY]) } - val dark = when (settings.themeMode) { - ThemeMode.SYSTEM -> isSystemInDarkTheme() - ThemeMode.LIGHT -> false - ThemeMode.DARK -> true - } - AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) { + val dark = settings.themeMode.isDark(isSystemInDarkTheme()) + AgendulaTheme( + darkTheme = dark, + dynamicColor = settings.dynamicColor, + pureBlack = settings.themeMode.isPureBlack, + ) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} WidgetFilterPicker( kind = kind, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt index e1c0c34..c5e626c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt @@ -82,7 +82,7 @@ internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMo override ?: when (appThemeMode) { ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM ThemeMode.LIGHT -> WidgetThemeMode.LIGHT - ThemeMode.DARK -> WidgetThemeMode.DARK + ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK } /** The smart lists a widget can show, in picker order. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a5dfb7..19045a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -448,6 +448,8 @@ Follow system Light Dark + Black + Dark with pure black backgrounds, for AMOLED screens Dynamic colour Use colours from your wallpaper Notifications diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt index 1951ca0..a054a77 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt @@ -210,6 +210,7 @@ class WidgetDataTest { fun `no override resolves to whatever the app's own theme means`() { assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK) assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM) + assertThat(readyWithTheme(ThemeMode.BLACK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK) } @Test From c26ff5909b48cf169b11fef623d4e54fae827d93 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 14:30:12 +0100 Subject: [PATCH 3/6] Switchable launcher name: Agendula or Tasks (#104) (#110) --- 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 acaa3f9..e08484b 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) { @@ -164,6 +189,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 19045a3..d623542 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 @@ -450,6 +451,8 @@ Dark Black Dark with pure black backgrounds, for AMOLED screens + App name + Show Agendula as “Tasks” in your launcher. Only the launcher name changes; the icon may move to a new spot. Agendula closes when you switch, so reopen it from the launcher. 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() + } +} From 63c7aa64e25e55b39edeb36f08815221c5dbf30c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 14:30:27 +0100 Subject: [PATCH 4/6] Ignore task swipes that start at the screen edge (#90) (#111) --- .../agendula/ui/common/EdgeSwipeGuard.kt | 66 +++++++++++++++++++ .../agendula/ui/tasklist/TaskListScreen.kt | 5 +- 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/EdgeSwipeGuard.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/EdgeSwipeGuard.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/EdgeSwipeGuard.kt new file mode 100644 index 0000000..d138ae9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/EdgeSwipeGuard.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.systemGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp +import kotlin.math.abs + +/** How far past the system back-gesture zone a horizontal swipe is still ignored. */ +private val EDGE_MARGIN = 24.dp + +/** + * Swallows horizontal swipes that start near the screen's left or right edge, + * before a swipe box below it sees them (#90). A back gesture started just + * inside the system's gesture zone otherwise reaches the app as a row swipe. + * Taps and vertical scrolls are left alone. + */ +@Composable +fun Modifier.ignoringEdgeSwipes(): Modifier { + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val windowWidth = LocalWindowInfo.current.containerSize.width + val insets = WindowInsets.systemGestures + val margin = with(density) { EDGE_MARGIN.roundToPx() } + val leftEdge = insets.getLeft(density, layoutDirection) + margin + val rightEdge = windowWidth - insets.getRight(density, layoutDirection) - margin + var offsetX by remember { mutableFloatStateOf(0f) } + return this + .onGloballyPositioned { offsetX = it.positionInWindow().x } + .pointerInput(leftEdge, rightEdge) { + val slop = viewConfiguration.touchSlop + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val x = offsetX + down.position.x + if (x > leftEdge && x < rightEdge) return@awaitEachGesture + var horizontal = false + while (true) { + val change = awaitPointerEvent(PointerEventPass.Initial).changes + .firstOrNull { it.id == down.id } ?: break + if (!horizontal) { + val delta = change.position - down.position + when { + abs(delta.x) > slop -> horizontal = true + abs(delta.y) > slop -> break + } + } + if (horizontal) change.consume() + if (!change.pressed) break + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index 337cd6f..d7bde7c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -112,6 +112,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.agendula.ui.common.ignoringEdgeSwipes import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage @@ -655,7 +656,7 @@ private fun TaskRow( SwipeToDismissBox( state = dismissState, - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), + modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap), // dismissDirection (not targetValue) tracks the live drag, so the colour + // icon reveal as you swipe rather than only once the threshold is crossed. backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, @@ -1037,7 +1038,7 @@ private fun SubtaskRow( val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp) SwipeToDismissBox( state = dismissState, - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), + modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap), backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, ) { Surface( From df1fb4e46b6ab2cbe0986b79c376f7650316870b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 14:30:36 +0100 Subject: [PATCH 5/6] Collapsible task list sections (#91) (#112) --- .../agendula/data/prefs/SettingsPrefs.kt | 14 +++++++ .../agendula/ui/tasklist/TaskListScreen.kt | 38 +++++-------------- .../agendula/ui/tasklist/TaskListViewModel.kt | 8 ++++ 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 73f09c2..dd67d3b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.asTaskReminder import de.jeanlucmakiola.agendula.domain.asReminderLead import de.jeanlucmakiola.agendula.domain.TaskSortOrder @@ -54,6 +55,7 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) { } const val DEFAULT_SNOOZE_MINUTES = 10 +/** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set = setOf(TaskSection.COMPLETED) /** The snooze lengths Settings offers. */ val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) @@ -114,6 +116,8 @@ data class Settings( val hiddenFromSmartLists: Set = emptySet(), /** Pre-fill a new task's start with today's date. */ val defaultStartToday: Boolean = false, + /** Task-list sections folded away; Done starts folded. */ + val collapsedSections: Set = DEFAULT_COLLAPSED_SECTIONS, ) { /** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */ fun visibleIn(filter: TaskFilter, tasks: List): List = @@ -179,9 +183,18 @@ class SettingsPrefs @Inject constructor( .toSet() }, defaultStartToday = p[DEFAULT_START_TODAY] ?: false, + collapsedSections = p[COLLAPSED_SECTIONS] + ?.mapNotNull { name -> runCatching { TaskSection.valueOf(name) }.getOrNull() } + ?.toSet() + ?: DEFAULT_COLLAPSED_SECTIONS, ) } + suspend fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) = dataStore.edit { p -> + val current = p[COLLAPSED_SECTIONS] ?: DEFAULT_COLLAPSED_SECTIONS.mapTo(HashSet()) { it.name } + p[COLLAPSED_SECTIONS] = if (collapsed) current + section.name else current - section.name + } + suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name } suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) } @@ -339,6 +352,7 @@ class SettingsPrefs @Inject constructor( val PUSH_ENABLED = booleanPreferencesKey("push_enabled") val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden") val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today") + val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections") val SAVED_FILTERS = stringPreferencesKey("saved_filters") val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id") } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index d7bde7c..ee3a26a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -372,8 +372,6 @@ private fun TaskListBody( // rows; inside a single real list both are redundant. val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged || (filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1) - // Completed tasks collapse away by default to keep the active list in focus. - var completedExpanded by rememberSaveable { mutableStateOf(false) } // Parents the user expanded to reveal their subtasks inline. Children are // grouped from the same flat task set the list already holds. var expandedParents by rememberSaveable { mutableStateOf(emptySet()) } @@ -421,23 +419,20 @@ private fun TaskListBody( } sections.forEach { section -> - val collapsible = showHeaders && section.section == TaskSection.COMPLETED + // Every header folds its section away (#91); Done starts folded. + val expanded = !showHeaders || section.section !in state.collapsedSections if (showHeaders) { stickyHeader(key = "hdr-${section.section}") { - if (collapsible) { - CollapsibleSectionHeader( - label = sectionLabel(section.section), - count = section.tasks.size, - expanded = completedExpanded, - onToggle = { completedExpanded = !completedExpanded }, - onClear = onClearCompleted, - ) - } else { - SectionHeader(sectionLabel(section.section)) - } + CollapsibleSectionHeader( + label = sectionLabel(section.section), + count = section.tasks.size, + expanded = expanded, + onToggle = { viewModel.setSectionCollapsed(section.section, expanded) }, + onClear = onClearCompleted.takeIf { section.section == TaskSection.COMPLETED }, + ) } } - if (!collapsible || completedExpanded) { + if (expanded) { // Flatten the section into one grouped run: each parent, followed // by its subtasks when expanded. Each row's corners then come from // its place in the whole run (positionOf below) — so the children @@ -1128,19 +1123,6 @@ private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, sma Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full) } -@Composable -private fun SectionHeader(text: String) { - // Opaque surface so rows scroll cleanly under the pinned header. - Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { - Text( - text = text, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), - ) - } -} - /** A tappable section header with a count and a chevron that rotates on expand. */ @Composable private fun CollapsibleSectionHeader( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index fdf75d7..68b88f6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.tasklist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_COLLAPSED_SECTIONS import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.tasks.TasksRepository @@ -14,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.tagsInUse import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSortOrder import de.jeanlucmakiola.agendula.domain.comparator import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure @@ -56,6 +58,8 @@ sealed interface TaskListUiState { /** Whether a real list uses the bottom quick-add bar instead of the FAB. */ val bottomAddBar: Boolean = false, val sortOrder: TaskSortOrder = TaskSortOrder.DUE, + /** Sections whose rows are folded under their header. */ + val collapsedSections: Set = DEFAULT_COLLAPSED_SECTIONS, /** This real list is kept out of the smart lists. */ val hiddenFromSmartLists: Boolean = false, /** Tags the unnarrowed view carries, offered as filter chips. */ @@ -144,6 +148,7 @@ class TaskListViewModel @Inject constructor( showAddSubtaskRow = settings.showAddSubtaskRow, bottomAddBar = settings.bottomAddBar, sortOrder = settings.sortOrder, + collapsedSections = settings.collapsedSections, hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false, ) } else { @@ -277,6 +282,9 @@ class TaskListViewModel @Inject constructor( fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) } + fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) = + viewModelScope.launch { settingsPrefs.setSectionCollapsed(section, collapsed) } + fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) } /** From 2c450d300f6a8e7e7080a97e58492f657119eee8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 14:30:47 +0100 Subject: [PATCH 6/6] Return home after adding or opening a task from a widget (#93) (#113) --- .../jeanlucmakiola/agendula/MainActivity.kt | 15 ++++++++++++--- .../jeanlucmakiola/agendula/ui/RootScreen.kt | 4 ++++ .../agendula/ui/navigation/AgendulaNavHost.kt | 19 +++++++++++++++++++ .../agendula/ui/navigation/NavRequest.kt | 18 +++++++++++++++--- .../agendula/widget/TaskWidget.kt | 2 +- .../agendula/widget/UpNextWidget.kt | 2 +- 6 files changed, 52 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt index f6386e4..9a47ca4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt @@ -104,6 +104,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), navRequest = navRequest, onNavRequestConsumed = { navRequest = null }, + onReturnHome = ::finish, ) pendingCrashReport?.let { report -> CrashReportDialog( @@ -152,6 +153,7 @@ class MainActivity : ComponentActivity() { private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID" private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST" private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID" + private const val EXTRA_RETURN_HOME = "de.jeanlucmakiola.agendula.extra.RETURN_HOME" private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID" private const val SHARED_TITLE_LIMIT = 500 private const val NO_OCCURRENCE = -1L @@ -160,10 +162,16 @@ class MainActivity : ComponentActivity() { * Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis) * picks the occurrence of a recurring task. */ - fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent = + fun taskIntent( + context: Context, + taskId: Long, + occurrenceStart: Long? = null, + returnHome: Boolean = false, + ): Intent = Intent(context, MainActivity::class.java).apply { putExtra(EXTRA_TASK_ID, taskId) putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE) + putExtra(EXTRA_RETURN_HOME, returnHome) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } @@ -215,6 +223,7 @@ class MainActivity : ComponentActivity() { when (intent.action) { ACTION_NEW_TASK -> return NavRequest.NewTask( listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L }, + returnHome = true, ) ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY) ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST) @@ -229,12 +238,12 @@ class MainActivity : ComponentActivity() { if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) { val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() } ?: intent.getStringExtra(Intent.EXTRA_TEXT) - return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT)) + return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT), returnHome = true) } val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE) .takeIf { it != NO_OCCURRENCE } - return NavRequest.OpenTask(taskId, occurrence) + return NavRequest.OpenTask(taskId, occurrence, intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt index 0e6da56..86e4a59 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt @@ -46,6 +46,7 @@ fun RootScreen( modifier: Modifier = Modifier, navRequest: NavRequest? = null, onNavRequestConsumed: () -> Unit = {}, + onReturnHome: () -> Unit = {}, permissionViewModel: PermissionViewModel = hiltViewModel(), ) { val permission by permissionViewModel.state.collectAsStateWithLifecycle() @@ -86,6 +87,7 @@ fun RootScreen( modifier = modifier, navRequest = navRequest, onNavRequestConsumed = onNavRequestConsumed, + onReturnHome = onReturnHome, ) } } @@ -101,6 +103,7 @@ private fun ReadyGate( modifier: Modifier = Modifier, navRequest: NavRequest? = null, onNavRequestConsumed: () -> Unit = {}, + onReturnHome: () -> Unit = {}, onboardingViewModel: OnboardingViewModel = hiltViewModel(), ) { val done by onboardingViewModel.done.collectAsStateWithLifecycle() @@ -113,6 +116,7 @@ private fun ReadyGate( modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), navRequest = navRequest, onNavRequestConsumed = onNavRequestConsumed, + onReturnHome = onReturnHome, ) false -> ReadableWidth(modifier) { OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt index 0365733..5500760 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt @@ -7,6 +7,11 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Modifier @@ -46,8 +51,19 @@ fun AgendulaNavHost( modifier: Modifier = Modifier, navRequest: NavRequest? = null, onNavRequestConsumed: () -> Unit = {}, + onReturnHome: () -> Unit = {}, ) { val nav = rememberNavController() + // The entry a widget, tile, shortcut or share opened; closing it leaves the app (#93). + var returnHomeEntry by rememberSaveable { mutableStateOf(null) } + val backStack by nav.currentBackStack.collectAsState() + LaunchedEffect(backStack, returnHomeEntry) { + val id = returnHomeEntry ?: return@LaunchedEffect + if (backStack.none { it.id == id }) { + returnHomeEntry = null + onReturnHome() + } + } LaunchedEffect(navRequest) { when (navRequest) { @@ -71,6 +87,9 @@ fun AgendulaNavHost( } is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri)) } + val returnHome = (navRequest as? NavRequest.NewTask)?.returnHome == true || + (navRequest as? NavRequest.OpenTask)?.returnHome == true + returnHomeEntry = if (returnHome) nav.currentBackStackEntry?.id else null onNavRequestConsumed() } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt index 3743cb3..5adf6df 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt @@ -9,8 +9,15 @@ import de.jeanlucmakiola.agendula.domain.SmartList * [AgendulaNavHost], which pushes it over whatever is showing. */ sealed interface NavRequest { - /** One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. */ - data class OpenTask(val taskId: Long, val occurrenceStart: Long? = null) : NavRequest + /** + * One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. + * [returnHome] leaves the app once the detail is closed (a widget row, #93). + */ + data class OpenTask( + val taskId: Long, + val occurrenceStart: Long? = null, + val returnHome: Boolean = false, + ) : NavRequest /** Settings → Accounts, where a sync report's detail lives. */ data object OpenAccounts : NavRequest @@ -21,8 +28,13 @@ sealed interface NavRequest { /** * A new task, optionally titled with text shared from another app, or * preset to a list — a widget's own add button, when it shows one list. + * [returnHome] leaves the app once the form is closed (#93). */ - data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest + data class NewTask( + val title: String? = null, + val listId: Long? = null, + val returnHome: Boolean = false, + ) : NavRequest /** One of the smart lists — the launcher's "Today" shortcut, a widget header. */ data class OpenSmart(val list: SmartList) : NavRequest diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt index c4eedcf..247d762 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -328,7 +328,7 @@ internal fun AgendaTaskRow( val context = LocalContext.current val title = row.title.ifBlank { context.getString(R.string.task_untitled) } val open = actionStartActivity( - MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()), + MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true), ) val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt index ad3098a..0336f9e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt @@ -339,7 +339,7 @@ private fun ListDot(color: Int) { } private fun openTask(context: Context, row: WidgetTaskRow): Action = - actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds())) + actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true)) private fun completeTask(row: WidgetTaskRow): Action = actionRunCallback(