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/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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( From b8264e336f948bb6791fb70e78059f67462835a0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 14:31:19 +0100 Subject: [PATCH 7/8] Pull to sync (#73) (#114) --- .../agendula/data/di/SyncModule.kt | 6 ++ .../agendula/data/sync/PullSync.kt | 76 +++++++++++++++++++ .../agendula/data/sync/ManualSync.kt | 23 ++++++ .../agendula/ui/common/SyncPullBox.kt | 51 +++++++++++++ .../agendula/ui/lists/ListsScreen.kt | 10 ++- .../agendula/ui/lists/ListsViewModel.kt | 9 +++ .../agendula/ui/tasklist/TaskListScreen.kt | 25 ++++-- .../agendula/ui/tasklist/TaskListViewModel.kt | 9 +++ .../agendula/data/di/SyncModule.kt | 4 + 9 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt index ac27212..f863553 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -15,8 +15,10 @@ import de.jeanlucmakiola.agendula.data.sync.AccountCreator import de.jeanlucmakiola.agendula.data.sync.AccountRepository import de.jeanlucmakiola.agendula.data.sync.CalDavGateway import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord +import de.jeanlucmakiola.agendula.data.sync.ManualSync import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore +import de.jeanlucmakiola.agendula.data.sync.PullSync import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier @@ -81,6 +83,10 @@ abstract class SyncBindModule { @Singleton abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests + @Binds + @Singleton + abstract fun bindManualSync(impl: PullSync): ManualSync + @Binds @IntoSet abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt new file mode 100644 index 0000000..3e1f1fc --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.content.Context +import androidx.work.WorkInfo +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Pull to sync over [SyncTrigger]: an expedited sync per account, and + * [syncing] held until they finish. + * + * A sync waiting on its network constraint never starts, so if none is running + * after [START_GRACE] the indicator lets go; the work stays queued and runs + * once the device is back online. + */ +@Singleton +class PullSync @Inject constructor( + @ApplicationContext private val context: Context, + private val accounts: AccountRepository, + private val trigger: SyncTrigger, + availability: SyncAvailability, + @ApplicationScope private val scope: CoroutineScope, +) : ManualSync { + + override val available: Flow = + combine(availability.observe(), accounts.observeAll()) { usable, all -> usable && all.isNotEmpty() } + + private val _syncing = MutableStateFlow(false) + override val syncing: StateFlow = _syncing + + override fun syncAll() { + if (_syncing.value) return + _syncing.value = true + scope.launch { + try { + val names = accounts.syncable().map { trigger.enqueue(it.displayName, expedited = true) } + if (names.isNotEmpty()) awaitSyncs(names) + } finally { + _syncing.value = false + } + } + } + + private suspend fun awaitSyncs(names: List) { + val workManager = WorkManager.getInstance(context) + val states = combine(names.map { workManager.getWorkInfosForUniqueWorkFlow(it) }) { infos -> + infos.flatMap { it }.map { it.state } + } + withTimeoutOrNull(MAX_WAIT) { + val started = withTimeoutOrNull(START_GRACE) { + states.first { all -> WorkInfo.State.RUNNING in all || all.all { it.isFinished } } + } ?: return@withTimeoutOrNull + if (started.all { it.isFinished }) return@withTimeoutOrNull + states.map { all -> WorkInfo.State.RUNNING in all }.first { running -> !running } + } + } + + private companion object { + val START_GRACE = 3.seconds + val MAX_WAIT = 2.minutes + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt new file mode 100644 index 0000000..0f3f89b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt @@ -0,0 +1,23 @@ +package de.jeanlucmakiola.agendula.data.sync + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf + +/** Pull to sync (#73): syncs every account at once. Inert in the `offline` flavor. */ +interface ManualSync { + /** Whether there is anything to sync: our own store, with at least one account. */ + val available: Flow + + /** True from [syncAll] until the syncs it started have finished. */ + val syncing: StateFlow + + fun syncAll() + + object None : ManualSync { + override val available: Flow = flowOf(false) + override val syncing: StateFlow = MutableStateFlow(false) + override fun syncAll() = Unit + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt new file mode 100644 index 0000000..efccbbf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Pull down to sync every account (#73). Without an account there is nothing + * to sync, so the gesture is off. [topPadding] keeps the indicator below a top + * bar the content scrolls under. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SyncPullBox( + available: Boolean, + syncing: Boolean, + onSync: () -> Unit, + modifier: Modifier = Modifier, + topPadding: Dp = 0.dp, + content: @Composable BoxScope.() -> Unit, +) { + if (!available) { + Box(modifier, content = content) + return + } + val state = rememberPullToRefreshState() + PullToRefreshBox( + isRefreshing = syncing, + onRefresh = onSync, + modifier = modifier, + state = state, + indicator = { + PullToRefreshDefaults.LoadingIndicator( + state = state, + isRefreshing = syncing, + modifier = Modifier.align(Alignment.TopCenter).padding(top = topPadding), + ) + }, + content = content, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index f64cd57..52906ad 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -97,6 +97,7 @@ import kotlinx.coroutines.delay import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton +import de.jeanlucmakiola.agendula.ui.common.SyncPullBox import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.floret.components.positionOf @@ -122,6 +123,8 @@ fun ListsScreen( viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle() + val syncing by viewModel.syncing.collectAsStateWithLifecycle() var query by rememberSaveable { mutableStateOf("") } var searchActive by rememberSaveable { mutableStateOf(false) } var newList by rememberSaveable { mutableStateOf(false) } @@ -173,7 +176,12 @@ fun ListsScreen( } }, ) { inner -> - Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) { + SyncPullBox( + available = syncAvailable && !searchActive, + syncing = syncing, + onSync = viewModel::syncAll, + modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()), + ) { when (val s = state) { ListsUiState.Loading -> Unit // brief; avoids a flash before first emission ListsUiState.Failure -> diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index 90f2c18..838203e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.sync.ManualSync import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure @@ -105,9 +106,17 @@ private const val UPCOMING_PREVIEW = 3 class ListsViewModel @Inject constructor( private val repository: TasksRepository, private val remoteLists: RemoteLists, + private val manualSync: ManualSync, settingsPrefs: SettingsPrefs, ) : ViewModel() { + /** Pull to sync (#73); off without an account. */ + val syncAvailable: StateFlow = + manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val syncing: StateFlow = manualSync.syncing + + fun syncAll() = manualSync.syncAll() + /** * Where a new list may go: the device, then every account whose server said * it would take one. 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 ee3a26a..d6b1bef 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.SyncPullBox import de.jeanlucmakiola.agendula.ui.common.ignoringEdgeSwipes import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet @@ -145,6 +146,8 @@ fun TaskListScreen( viewModel: TaskListViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle() + val syncing by viewModel.syncing.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val content = state as? TaskListUiState.Content val list = content?.list @@ -248,13 +251,21 @@ fun TaskListScreen( }, ) { inner -> Box(modifier = Modifier.fillMaxSize()) { - when (val s = state) { - TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission - TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner) - is TaskListUiState.Content -> TaskListBody( - s, filter, inner, onOpenTask, onRequestDelete, viewModel, - onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null, - ) + SyncPullBox( + available = syncAvailable, + syncing = syncing, + onSync = viewModel::syncAll, + modifier = Modifier.fillMaxSize(), + topPadding = inner.calculateTopPadding(), + ) { + when (val s = state) { + TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission + TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner) + is TaskListUiState.Content -> TaskListBody( + s, filter, inner, onOpenTask, onRequestDelete, viewModel, + onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null, + ) + } } // A FAB-height band anchored at the bottom-start with the FAB's own // margin; centring the chip in it lines the chip up beside the bottom-end 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 68b88f6..b74fece 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 @@ -21,6 +21,7 @@ import de.jeanlucmakiola.agendula.domain.comparator import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import de.jeanlucmakiola.agendula.ui.lists.asFailure import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.sync.ManualSync import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -93,9 +94,17 @@ class TaskListViewModel @Inject constructor( private val repository: TasksRepository, private val settingsPrefs: SettingsPrefs, private val remoteLists: RemoteLists, + private val manualSync: ManualSync, @ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { + /** Pull to sync (#73); off without an account. */ + val syncAvailable: StateFlow = + manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val syncing: StateFlow = manualSync.syncing + + fun syncAll() = manualSync.syncAll() + private val filter = MutableStateFlow(null) private val tagFilter = MutableStateFlow>(emptySet()) diff --git a/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt index 1a3d7fc..c796b4b 100644 --- a/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt +++ b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -4,6 +4,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.agendula.data.sync.ManualSync import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity @@ -26,6 +27,9 @@ object SyncModule { @Provides fun provideRemoteLists(): RemoteLists = NoRemoteLists + + @Provides + fun provideManualSync(): ManualSync = ManualSync.None } private object NoRemoteLists : RemoteLists { From 3452fd631bda6e98be2b3a82f87cfc76d79c3422 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 14:31:28 +0100 Subject: [PATCH 8/8] Name an account (#105) (#115) --- .../4.json | 553 ++++++++++++++++++ .../tasks/room/TasksDatabaseMigrationTest.kt | 15 + .../agendula/data/di/SyncModule.kt | 3 +- .../agendula/data/sync/AccountRepository.kt | 5 + .../agendula/data/sync/AccountSyncRequests.kt | 20 + .../agendula/data/sync/SyncTrigger.kt | 6 +- .../ui/accounts/AccountDetailScreen.kt | 53 ++ .../agendula/ui/accounts/AccountIdentity.kt | 10 +- .../agendula/ui/accounts/AccountsViewModel.kt | 4 + .../ui/accounts/add/AddAccountViewModel.kt | 6 +- .../agendula/data/di/DataModule.kt | 2 +- .../agendula/data/sync/SyncRequests.kt | 2 +- .../agendula/data/tasks/room/AccountDao.kt | 3 + .../agendula/data/tasks/room/Entities.kt | 2 + .../agendula/data/tasks/room/Projections.kt | 2 + .../data/tasks/room/RoomTaskMapper.kt | 4 +- .../agendula/data/tasks/room/TaskDao.kt | 6 +- .../agendula/data/tasks/room/TaskListDao.kt | 4 +- .../agendula/data/tasks/room/TasksDatabase.kt | 9 +- .../agendula/ui/imports/ImportViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 2 + .../data/tasks/room/RoomTaskMapperTest.kt | 12 + .../accounts/add/AddAccountViewModelTest.kt | 15 +- 23 files changed, 719 insertions(+), 21 deletions(-) create mode 100644 app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json new file mode 100644 index 0000000..5502a50 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json @@ -0,0 +1,553 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "47f3bedbfbf4ddd1db32864c718de83a", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT, `label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principalUrl", + "columnName": "principal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "homeSetUrl", + "columnName": "home_set_url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncError", + "columnName": "last_sync_error", + "affinity": "TEXT" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isSynced", + "columnName": "is_synced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "ctag", + "columnName": "ctag", + "affinity": "TEXT" + }, + { + "fieldPath": "syncToken", + "columnName": "sync_token", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_lists_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "list_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "percentComplete", + "columnName": "percent_complete", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "classification", + "columnName": "classification", + "affinity": "INTEGER" + }, + { + "fieldPath": "dtstart", + "columnName": "dtstart", + "affinity": "INTEGER" + }, + { + "fieldPath": "due", + "columnName": "due", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "isAllDay", + "columnName": "is_all_day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT" + }, + { + "fieldPath": "rrule", + "columnName": "rrule", + "affinity": "TEXT" + }, + { + "fieldPath": "rdate", + "columnName": "rdate", + "affinity": "TEXT" + }, + { + "fieldPath": "exdate", + "columnName": "exdate", + "affinity": "TEXT" + }, + { + "fieldPath": "recurrenceId", + "columnName": "recurrence_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "masterId", + "columnName": "master_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unknownProperties", + "columnName": "unknown_properties", + "affinity": "TEXT" + }, + { + "fieldPath": "remindersOff", + "columnName": "reminders_off", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_list_id_is_deleted", + "unique": false, + "columnNames": [ + "list_id", + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)" + }, + { + "name": "index_tasks_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_tasks_master_id_recurrence_id", + "unique": false, + "columnNames": [ + "master_id", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)" + }, + { + "name": "index_tasks_is_dirty", + "unique": false, + "columnNames": [ + "is_dirty" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)" + }, + { + "name": "index_tasks_list_id_uid_recurrence_id", + "unique": true, + "columnNames": [ + "list_id", + "uid", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)" + } + ], + "foreignKeys": [ + { + "table": "task_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "master_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesBefore", + "columnName": "minutes_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DUE'" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_alarms_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '47f3bedbfbf4ddd1db32864c718de83a')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt index a6fa376..9e37f7e 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -147,6 +147,21 @@ class TasksDatabaseMigrationTest { } } + @Test + fun addsAnEmptyAccountLabel() { + helper.createDatabase(TEST_DB, 3).use { db -> + db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me@example.org')") + } + + helper.runMigrationsAndValidate(TEST_DB, 4, true, TasksDatabase.MIGRATION_3_4).use { db -> + db.query("SELECT display_name, label FROM accounts").use { c -> + assertThat(c.moveToFirst()).isTrue() + assertThat(c.getString(0)).isEqualTo("me@example.org") + assertThat(c.isNull(1)).isTrue() + } + } + } + private companion object { const val TEST_DB = "migration-test.db" } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt index f863553..d8a6b34 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -13,6 +13,7 @@ import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet import de.jeanlucmakiola.agendula.data.sync.AccountCreator import de.jeanlucmakiola.agendula.data.sync.AccountRepository +import de.jeanlucmakiola.agendula.data.sync.AccountSyncRequests import de.jeanlucmakiola.agendula.data.sync.CalDavGateway import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord import de.jeanlucmakiola.agendula.data.sync.ManualSync @@ -81,7 +82,7 @@ abstract class SyncBindModule { @Binds @Singleton - abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests + abstract fun bindSyncRequests(impl: AccountSyncRequests): SyncRequests @Binds @Singleton diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt index c77a6d5..505a80f 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt @@ -97,6 +97,11 @@ class AccountRepository @Inject constructor( /** The accounts, observed, so a sync landing updates a screen that is open. */ fun observeAll(): Flow> = database.accounts().observeAll() + /** Names the account on screen; blank goes back to the default (#105). */ + suspend fun setLabel(accountId: Long, label: String) = withContext(io) { + database.accounts().setLabel(accountId, label.trim().ifEmpty { null }) + } + /** Every account's synced lists. */ fun observeSyncedLists(): Flow> = database.taskLists().observeSynced() diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt new file mode 100644 index 0000000..9f29c61 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.agendula.data.sync + +import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** [SyncRequests] by account id; [SyncTrigger] keys on the account's stable display name. */ +class AccountSyncRequests @Inject constructor( + private val database: TasksDatabase, + private val trigger: SyncTrigger, + @IoDispatcher private val io: CoroutineDispatcher, +) : SyncRequests { + + override suspend fun syncNow(accountId: Long) { + val name = withContext(io) { database.accounts().account(accountId)?.displayName } ?: return + trigger.enqueue(name) + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt index 9818c7b..fc796f2 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt @@ -35,11 +35,7 @@ import kotlin.time.Duration.Companion.seconds @Singleton class SyncTrigger @Inject constructor( @ApplicationContext private val context: Context, -) : SyncRequests { - - override fun syncNow(accountName: String) { - enqueue(accountName) - } +) { /** * Starts a sync now. diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt index 1a88396..cf8e4dd 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.HistoryToggleOff import androidx.compose.material.icons.rounded.Inventory2 @@ -26,6 +27,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -46,9 +48,11 @@ import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit import de.jeanlucmakiola.agendula.data.sync.SyncNotice import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity import de.jeanlucmakiola.agendula.ui.accounts.add.message +import de.jeanlucmakiola.agendula.ui.common.NameField import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedHint import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position @@ -90,12 +94,34 @@ internal fun AccountDetailScreen( // The confirmation sheet. The removal it starts outlives this screen, and is // shown on the accounts list rather than here. var confirming by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(false) } + + if (renaming) { + AccountNamePicker( + current = account.label.orEmpty(), + defaultTitle = account.defaultTitle(), + onSave = { name -> + viewModel.rename(account, name) + renaming = false + }, + onDismiss = { renaming = false }, + ) + } CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) { AccountHero(identity) Spacer(Modifier.height(24.dp)) val actions = buildList<@Composable (Position) -> Unit> { + add { position -> + GroupedRow( + title = stringResource(R.string.accounts_name), + summary = identity.title, + position = position, + leading = { Icon(Icons.Rounded.Edit, contentDescription = null) }, + onClick = { renaming = true }, + ) + } add { position -> GroupedRow( title = stringResource(R.string.accounts_last_sync), @@ -480,3 +506,30 @@ private fun errorTitle(text: String): AnnotatedString { buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } } } } + +/** Names an account on screen (#105); left empty, it goes back to [defaultTitle]. */ +@Composable +private fun AccountNamePicker( + current: String, + defaultTitle: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(current) } + FullScreenPicker( + title = stringResource(R.string.accounts_name), + onDismiss = onDismiss, + actions = { + TextButton(onClick = { onSave(name) }) { Text(stringResource(R.string.save)) } + }, + ) { + NameField( + name = name, + placeholder = defaultTitle, + autoFocus = true, + onNameChange = { name = it }, + onImeAction = { onSave(name) }, + ) + GroupedHint(stringResource(R.string.accounts_name_hint, defaultTitle)) + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt index 6506346..58ea126 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt @@ -44,6 +44,14 @@ internal data class AccountIdentity( val user: String?, ) +/** The title an account has without a [AccountEntity.label]. */ +internal fun AccountEntity.defaultTitle(): String { + val url = principalUrl?.toHttpUrlOrNull() + val provider = url?.let { CalDavProvider.forPrincipal(it) } + val host = url?.host?.removePrefix("www.") + return if (provider?.hosted == true) provider.label else host ?: displayName +} + internal fun AccountEntity.identity(): AccountIdentity { val url = principalUrl?.toHttpUrlOrNull() val provider = url?.let { CalDavProvider.forPrincipal(it) } @@ -53,7 +61,7 @@ internal fun AccountEntity.identity(): AccountIdentity { provider = provider, // The fallback is the stored name: an account with no principal URL // never got past discovery, so there is nothing better to call it. - title = if (hosted) provider.label else host ?: displayName, + title = label ?: defaultTitle(), secondary = if (hosted) host else provider?.label, user = username?.takeIf { it.isNotBlank() }, ) diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt index 4a4e062..6ca0d60 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt @@ -248,6 +248,10 @@ class AccountsViewModel @Inject constructor( syncTrigger.enqueue(account.displayName, expedited = true) } + fun rename(account: AccountEntity, label: String) { + viewModelScope.launch { repository.setLabel(account.id, label) } + } + /** The user has read what the last sync changed. */ fun dismissNotices(account: AccountEntity) { viewModelScope.launch { notices.dismiss(account.id) } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt index ef9e76b..53e4301 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt @@ -1386,10 +1386,14 @@ class AddAccountViewModel @Inject constructor( } } + // The port is part of the name: two servers on one host are two accounts (#105). private fun accountName(): String = - username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.host.orEmpty()}" } + username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.authority().orEmpty()}" } ?: typedInput + private fun HttpUrl.authority(): String = + if (port == HttpUrl.defaultPort(scheme)) host else "$host:$port" + internal companion object { const val POLL_INTERVAL_MILLIS = 2_000L diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index 5d93f08..7190753 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -89,7 +89,7 @@ object DataProvideModule { // backed-up `.db` does not. The backup rules carry all three files and // the app checkpoints on ON_STOP. .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - .addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3) + .addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4) .build() /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt index a95da71..419e14a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt @@ -2,5 +2,5 @@ package de.jeanlucmakiola.agendula.data.sync /** Asks for an account to be synced now. A no-op in the `offline` flavor. */ fun interface SyncRequests { - fun syncNow(accountName: String) + suspend fun syncNow(accountId: Long) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt index a734b9b..222f5ec 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt @@ -37,6 +37,9 @@ interface AccountDao { @Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId") fun recordSync(accountId: Long, at: Instant?, error: String?) + @Query("UPDATE accounts SET label = :label WHERE id = :accountId") + fun setLabel(accountId: Long, label: String?) + @Query("DELETE FROM accounts WHERE id = :accountId") fun delete(accountId: Long): Int } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt index cad74ee..d668783 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -26,6 +26,8 @@ data class AccountEntity( @ColumnInfo(name = "username") val username: String? = null, @ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null, @ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null, + /** A name the user gave the account (#105); [displayName] stays the stable key. */ + @ColumnInfo(name = "label") val label: String? = null, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt index 74f69d4..98c321c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt @@ -11,6 +11,7 @@ import androidx.room.Embedded data class TaskListRow( @Embedded val list: TaskListEntity, @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, + @ColumnInfo(name = "account_label") val accountLabel: String? = null, ) /** @@ -23,5 +24,6 @@ data class TaskRow( @ColumnInfo(name = "list_name") val listName: String, @ColumnInfo(name = "list_color") val listColor: Int, @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, + @ColumnInfo(name = "account_label") val accountLabel: String? = null, @ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt index 5ef7233..fb67ce8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -20,7 +20,7 @@ object RoomTaskMapper { color = row.list.color, // TaskList.accountName is non-null and the lists screen groups by it, so a // list with no account still has to report something to group under. - accountName = row.accountDisplayName ?: LocalAccount.NAME, + accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME, accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE, isSynced = row.list.isSynced, isVisible = row.list.isVisible, @@ -61,7 +61,7 @@ object RoomTaskMapper { listColor = row.listColor, taskColor = row.task.color, listName = row.listName, - accountName = row.accountDisplayName ?: LocalAccount.NAME, + accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME, parentId = row.task.parentId, isRecurring = row.task.isRecurring, occurrenceStart = occurrenceStart, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt index 52d28f4..747ef2d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -29,7 +29,7 @@ interface TaskDao { @Query( """ SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, - a.display_name AS account_display_name + a.display_name AS account_display_name, a.label AS account_label FROM tasks t JOIN task_lists l ON l.id = t.list_id LEFT JOIN accounts a ON a.id = l.account_id @@ -44,7 +44,7 @@ interface TaskDao { @Query( """ SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, - a.display_name AS account_display_name + a.display_name AS account_display_name, a.label AS account_label FROM tasks t JOIN task_lists l ON l.id = t.list_id LEFT JOIN accounts a ON a.id = l.account_id @@ -56,7 +56,7 @@ interface TaskDao { @Query( """ SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, - a.display_name AS account_display_name + a.display_name AS account_display_name, a.label AS account_label FROM tasks t JOIN task_lists l ON l.id = t.list_id LEFT JOIN accounts a ON a.id = l.account_id diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt index 863e507..a5d6bd8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt @@ -16,7 +16,7 @@ interface TaskListDao { @Query( """ - SELECT l.*, a.display_name AS account_display_name + SELECT l.*, a.display_name AS account_display_name, a.label AS account_label FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id ORDER BY a.display_name, l.sort_order, l.name """ @@ -25,7 +25,7 @@ interface TaskListDao { @Query( """ - SELECT l.*, a.display_name AS account_display_name + SELECT l.*, a.display_name AS account_display_name, a.label AS account_label FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id WHERE l.id = :listId """ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt index 9f9ac7e..6e24f41 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -22,7 +22,7 @@ import kotlin.time.Instant TaskEntity::class, TaskAlarmEntity::class, ], - version = 3, + version = 4, exportSchema = true, ) @TypeConverters(Converters::class) @@ -173,5 +173,12 @@ abstract class TasksDatabase : RoomDatabase() { ) } } + + /** v4 lets the user name an account (#105). */ + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT") + } + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt index 4c03174..239b770 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt @@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor( val result = importer.import(ready.parsed, target) (chosen as? ImportChoice.Existing)?.list ?.takeIf { it.accountId != null && result.imported > 0 } - ?.let { runCatching { syncRequests.syncNow(it.accountName) } } + ?.let { runCatching { syncRequests.syncNow(it.accountId!!) } } ImportStep.Done( listName = listName, imported = result.imported, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d623542..81cd474 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -686,6 +686,8 @@ Also deletes this account\u2019s lists and tasks from this device. Never synced Last sync + Name + How this account is shown in Agendula. Leave it empty to use “%1$s”. CalDAV account Last sync didn\u2019t finish Sync failed \u2014 the server didn\u2019t accept the password diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt index ea54286..83ef2a6 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt @@ -27,4 +27,16 @@ class RoomTaskMapperTest { assertThat(RoomTaskMapper.taskList(list).acceptsWrites).isFalse() } + + @Test + fun `an account's label is shown in place of its stored name`() { + val list = TaskListRow( + list = TaskListEntity(id = 2, name = "Work", color = 0, accountId = 1), + accountDisplayName = "me@host", + accountLabel = "Work server", + ) + + assertThat(RoomTaskMapper.taskList(list).accountName).isEqualTo("Work server") + assertThat(RoomTaskMapper.taskList(list.copy(accountLabel = null)).accountName).isEqualTo("me@host") + } } diff --git a/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt index ee123ff..dd3d34c 100644 --- a/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt +++ b/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt @@ -1307,6 +1307,15 @@ class AddAccountViewModelTest { assertThat(vm.state.value.fatal).isEqualTo(AddAccountMessage.ExternalStorage) } + @Test + fun `a server on its own port is named with it`() = runTest(dispatcher) { + val vm = signedIn("https://cloud.example.com:5233/") + vm.onSave() + advanceUntilIdle() + + assertThat(creator.created).containsExactly("me@cloud.example.com:5233") + } + @Test fun `a completed add reports the account it created`() = runTest(dispatcher) { val vm = signedIn() @@ -1406,12 +1415,14 @@ class AddAccountViewModelTest { * submit, so going straight from the address to a `Found` leaves the * username and password empty. */ - private fun kotlinx.coroutines.test.TestScope.signedIn(): AddAccountViewModel { + private fun kotlinx.coroutines.test.TestScope.signedIn( + address: String = "https://cloud.example.com/", + ): AddAccountViewModel { gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) gateway.loginFlow = null val vm = viewModel() - vm.enterAddress("https://cloud.example.com/") + vm.enterAddress(address) vm.onAddressSubmitted() advanceUntilIdle()