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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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,
@@ -105,6 +104,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
navRequest = navRequest,
onNavRequestConsumed = { navRequest = null },
+ onReturnHome = ::finish,
)
pendingCrashReport?.let { report ->
CrashReportDialog(
@@ -153,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
@@ -161,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)
}
@@ -216,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)
@@ -230,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/data/appname/LauncherNameManager.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManager.kt
new file mode 100644
index 0000000..84813b2
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/appname/LauncherNameManager.kt
@@ -0,0 +1,86 @@
+package de.jeanlucmakiola.agendula.data.appname
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** The label the launcher shows for the app (#104). */
+enum class LauncherName { AGENDULA, TASKS }
+
+/** The two launcher aliases declared in the manifest. */
+enum class LauncherAlias { DEFAULT, TASKS }
+
+/** One component-enable change in an [aliasWritePlan]. */
+data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
+
+/**
+ * Reads the `TasksNameAlias` enabled state as a [LauncherName]. Only an explicit
+ * enable means "Tasks"; never toggled and disabled both mean the manifest default.
+ */
+fun launcherNameFor(tasksAliasState: Int): LauncherName =
+ if (tasksAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
+ LauncherName.TASKS
+ } else {
+ LauncherName.AGENDULA
+ }
+
+/**
+ * The steps to switch to [target]: enable the target alias first, then disable
+ * the other, so the launcher can briefly see two entries but never none.
+ */
+fun aliasWritePlan(target: LauncherName): List = 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/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt
index 15d6244..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
@@ -31,7 +32,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 }
@@ -43,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)
@@ -103,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 =
@@ -168,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) }
@@ -328,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/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/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/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/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/ui/settings/AppearanceScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt
index 7c1ce72..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) {
@@ -140,6 +165,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 },
)
@@ -163,8 +189,17 @@ 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
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/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/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt
index 64f7e05..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
@@ -113,6 +113,7 @@ 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
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
@@ -382,8 +383,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()) }
@@ -431,23 +430,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
@@ -666,7 +662,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) },
@@ -1048,7 +1044,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(
@@ -1138,19 +1134,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 09ce19e..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
@@ -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
@@ -57,6 +59,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. */
@@ -153,6 +157,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 {
@@ -286,6 +291,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) }
/**
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/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/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/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(
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..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
@@ -448,6 +449,10 @@
Follow system
Light
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()
+ }
+}
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")
+ }
+}
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