Compare commits

...
Author SHA1 Message Date
makiolaj 7b24a02ac2 Merge release/v1.3.0 into feat/104-app-name 2026-10-28 14:29:46 +01:00
Jean-Luc Makiola c1219a434d Pure black theme for AMOLED screens (#99) (#109)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:29:25 +01:00
Jean-Luc Makiola 5dc1d49295 Show the task description in reminder notifications (#94) (#108)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:28:45 +01:00
makiolaj fb67f5e02f fix: warn that switching the app name closes Agendula (#104) 2026-10-28 14:26:33 +01:00
makiolaj 4a3da0963b feat: switchable launcher name, Agendula or Tasks (#104) 2026-10-28 12:03:41 +01:00
14 changed files with 270 additions and 22 deletions
+28 -4
View File
@@ -65,10 +65,8 @@
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- MAIN/LAUNCHER lives on the two aliases below, so the launcher
name can be switched at runtime (#104). -->
<!-- Shared text becomes a new task's title. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
@@ -89,6 +87,32 @@
<data android:mimeType="text/calendar" />
</intent-filter>
</activity>
<activity-alias
android:name=".DefaultNameAlias"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".TasksNameAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_tasks_alias"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
<service
@@ -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,
@@ -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<AliasStateChange> = 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 `<activity-alias>`
* 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"
}
}
@@ -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 }
@@ -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"
}
@@ -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
}
@@ -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<Boolean> = retryingLegacyImport
private val _launcherName = MutableStateFlow(LauncherName.AGENDULA)
val launcherName: StateFlow<LauncherName> = _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) }
@@ -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,
)
@@ -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,
@@ -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. */
+5
View File
@@ -1,5 +1,6 @@
<resources>
<string name="app_name">Agendula</string>
<string name="app_name_tasks_alias">Tasks</string>
<!-- Generic -->
<string name="task_untitled">Untitled task</string>
@@ -448,6 +449,10 @@
<string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_black">Black</string>
<string name="settings_theme_black_summary">Dark with pure black backgrounds, for AMOLED screens</string>
<string name="settings_app_name">App name</string>
<string name="settings_app_name_hint">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.</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Notifications</string>
@@ -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()
}
}
@@ -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")
}
}
@@ -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