Files
agendula/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt
T
Jean-Luc Makiolaandmakiolaj 7bcac70cec
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 1m7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m49s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped
Release 1.3.0 (#134)
Stable 1.3.0, after 1.3.0-beta.1 and beta.2.

- Version bumped to 1.3.0 (1030099); the CHANGELOG entries moved from Unreleased to 1.3.0.
- What's New for every shipped locale, now including ru-RU, which also gets its full store listing. values-uk and values-zh-rCN came in empty from Weblate, so they get no store locale yet.
- fr, ar, cs and ru added to locales_config.xml, so they show up in the in-app language picker and Android's per-app language settings. Before, only en, de and pt-BR did.
- `main` (Weblate #87) merged in, no conflicts and no stale keys.
- Lint has failed on this branch since #113: `NavController.currentBackStack` is a RestrictedApi. It's suppressed at that one call, since no public API exposes the whole back stack and `visibleEntries` drops an entry once another one covers it.

#119, #121, #122/#123 and #127/#129 landed after beta.2, so no tester has seen them yet.

Closes #68, closes #73, closes #90, closes #91, closes #93, closes #94, closes #99, closes #104, closes #105, closes #119, closes #121, closes #122, closes #123, closes #127, closes #129

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/134
2026-11-02 21:26:15 +01:00

452 lines
22 KiB
Kotlin

package de.jeanlucmakiola.agendula.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.lifecycle.lifecycleScope
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.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Which widget provider a configure session is for — decides which sections [WidgetFilterPicker] shows. */
internal enum class WidgetKind { TASKS, TODAY, UP_NEXT, LISTS, WEEK }
/** Reads [appWidgetId]'s provider class from the host, since that's the only thing naming which widget this is. */
private fun widgetKindOf(context: Context, appWidgetId: Int): WidgetKind? {
val provider = AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider ?: return null
return when (provider.className) {
TaskWidgetReceiver::class.java.name -> WidgetKind.TASKS
TodayWidgetReceiver::class.java.name -> WidgetKind.TODAY
UpNextWidgetReceiver::class.java.name -> WidgetKind.UP_NEXT
ListsWidgetReceiver::class.java.name -> WidgetKind.LISTS
WeekWidgetReceiver::class.java.name -> WidgetKind.WEEK
else -> null
}
}
private fun widgetFor(kind: WidgetKind): GlanceAppWidget = when (kind) {
WidgetKind.TASKS -> TaskWidget()
WidgetKind.TODAY -> TodayWidget()
WidgetKind.UP_NEXT -> UpNextWidget()
WidgetKind.LISTS -> ListsWidget()
WidgetKind.WEEK -> WeekWidget()
}
/**
* Picks what one widget instance shows. [WidgetKind.TASKS] picks a smart
* list or one or more real lists (with an optional "group by list" sort);
* [WidgetKind.LISTS] picks which lists get a tile; [WidgetKind.UP_NEXT] can be
* narrowed to one list; every other kind only
* offers background and theme, since they're always the smart-list scope.
* Runs when the widget is placed (below API 31) and on "reconfigure" from
* the launcher.
*/
@AndroidEntryPoint
class TaskWidgetConfigActivity : ComponentActivity() {
@Inject lateinit var repository: TasksRepository
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
?: AppWidgetManager.INVALID_APPWIDGET_ID
// Backing out cancels the placement, as the framework expects.
setResult(RESULT_CANCELED, resultIntent(appWidgetId))
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
val kind = widgetKindOf(this, appWidgetId) ?: WidgetKind.TASKS
enableEdgeToEdge()
val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId)
val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow())
.catch { emit(emptyList()) }
setContent {
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
val savedFilters by settingsPrefs.savedFilters.collectAsState(initial = emptyList())
var current by remember { mutableStateOf<TaskFilter?>(null) }
var groupByList by remember { mutableStateOf(false) }
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
var themeOverride by remember { mutableStateOf<WidgetThemeMode?>(null) }
var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
var selectedListIds by remember { mutableStateOf<Set<Long>?>(null) }
LaunchedEffect(glanceId) {
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY], kind)
themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY])
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
}
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,
lists = lists.filter { it.isVisible },
savedFilters = savedFilters,
dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER,
groupByList = groupByList,
onGroupByListChange = { groupByList = it },
background = background,
onBackgroundChange = { background = it },
themeOverride = themeOverride,
onThemeOverrideChange = { themeOverride = it },
rowColor = rowColor,
onRowColorChange = { rowColor = it },
selectedListIds = selectedListIds,
onSelectedListIdsChange = { selectedListIds = it },
onSave = { filter ->
lifecycleScope.launch {
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
when (kind) {
WidgetKind.TASKS -> {
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
it[WIDGET_ROW_COLOR_KEY] = rowColor.name
}
WidgetKind.LISTS, WidgetKind.UP_NEXT -> {
selectedListIds?.let { ids -> it[WIDGET_LIST_IDS_KEY] = ids.widgetListIdsStorageValue() }
?: it.remove(WIDGET_LIST_IDS_KEY)
}
WidgetKind.TODAY, WidgetKind.WEEK -> Unit
}
it[WIDGET_BACKGROUND_KEY] = background.name
themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name }
?: it.remove(WIDGET_THEME_KEY)
}
widgetFor(kind).update(this@TaskWidgetConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId))
finish()
}
},
onDismiss = { finish() },
)
}
}
}
private fun resultIntent(appWidgetId: Int) =
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
@Composable
private fun WidgetFilterPicker(
kind: WidgetKind,
lists: List<TaskList>,
savedFilters: List<SavedFilter>,
selected: TaskFilter,
dark: Boolean,
groupByList: Boolean,
onGroupByListChange: (Boolean) -> Unit,
background: WidgetBackground,
onBackgroundChange: (WidgetBackground) -> Unit,
themeOverride: WidgetThemeMode?,
onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
rowColor: WidgetRowColor,
onRowColorChange: (WidgetRowColor) -> Unit,
selectedListIds: Set<Long>?,
onSelectedListIdsChange: (Set<Long>?) -> Unit,
onSave: (TaskFilter) -> Unit,
onDismiss: () -> Unit,
) {
var showBackgroundPicker by remember { mutableStateOf(false) }
var showThemePicker by remember { mutableStateOf(false) }
var showRowColorPicker by remember { mutableStateOf(false) }
// Smart lists, saved filters and real lists are mutually exclusive; only real
// lists support picking more than one. `selectedSmart` or `selectedFilter`
// non-null is that pick; otherwise (or before the stored filter loads) it's
// a list pick.
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
var selectedFilter by remember(selected) { mutableStateOf((selected as? TaskFilter.Saved)?.id) }
var selectedLists by remember(selected) {
mutableStateOf(
when (selected) {
is TaskFilter.OfList -> setOf(selected.listId)
is TaskFilter.OfLists -> selected.listIds
is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> emptySet()
},
)
}
// A widget whose filter was deleted keeps the dead id until something else is picked.
val liveFilter = selectedFilter?.takeIf { id -> savedFilters.any { it.id == id } }
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || liveFilter != null ||
selectedLists.isNotEmpty()
FullScreenPicker(
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
onDismiss = onDismiss,
actions = {
TextButton(
enabled = canSave,
onClick = {
// The toggle row only shows (and so can only be turned off) with
// more than one list selected; narrowing back down to one, or to
// a smart list, has to clear it here instead; otherwise it's
// stuck on with no way left to see or undo it.
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
when {
selectedSmart != null -> onSave(TaskFilter.Smart(selectedSmart!!))
liveFilter != null -> onSave(TaskFilter.Saved(liveFilter))
else -> onSave(TaskFilter.OfLists(selectedLists))
}
},
) { Text(stringResource(android.R.string.ok)) }
},
) {
if (kind == WidgetKind.TASKS) {
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
val isSelected = smart == selectedSmart
GroupedRow(
title = stringResource(smartListLabel(smart)),
position = positionOf(index, WIDGET_SMART_LISTS.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { selectedSmart = smart; selectedFilter = null; selectedLists = emptySet() },
)
}
if (savedFilters.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.filters_header))
savedFilters.forEachIndexed { index, filter ->
val isSelected = filter.id == selectedFilter
GroupedRow(
title = filter.name,
position = positionOf(index, savedFilters.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { selectedFilter = filter.id; selectedSmart = null; selectedLists = emptySet() },
)
}
}
if (lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list ->
val isSelected = list.id in selectedLists
GroupedRow(
title = list.name,
position = positionOf(index, lists.size),
selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = {
selectedSmart = null
selectedFilter = null
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
},
)
}
if (selectedLists.size > 1) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.widget_config_group_by_list),
summary = stringResource(R.string.widget_config_group_by_list_hint),
position = Position.Alone,
trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
onClick = { onGroupByListChange(!groupByList) },
)
}
}
Spacer(Modifier.height(24.dp))
} else if (kind == WidgetKind.LISTS && lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list ->
// `null` means "every list" (the default) until the user narrows it down.
val isSelected = selectedListIds?.let { list.id in it } ?: true
GroupedRow(
title = list.name,
position = positionOf(index, lists.size),
selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = {
val current = selectedListIds ?: lists.map { it.id }.toSet()
onSelectedListIdsChange(if (isSelected) current - list.id else current + list.id)
},
)
}
Spacer(Modifier.height(24.dp))
} else if (kind == WidgetKind.UP_NEXT && lists.isNotEmpty()) {
// Single choice: `null` is every list, otherwise a one-element set.
val chosen = selectedListIds?.singleOrNull()
GroupedSectionHeader(stringResource(R.string.lists_header))
GroupedRow(
title = stringResource(R.string.widget_up_next_any_list),
position = positionOf(0, lists.size + 1),
selected = chosen == null,
trailing = if (chosen == null) ({ SelectedCheck() }) else null,
onClick = { onSelectedListIdsChange(null) },
)
lists.forEachIndexed { index, list ->
val isSelected = list.id == chosen
GroupedRow(
title = list.name,
position = positionOf(index + 1, lists.size + 1),
selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelectedListIdsChange(setOf(list.id)) },
)
}
Spacer(Modifier.height(24.dp))
}
GroupedRow(
title = stringResource(R.string.widget_config_background),
summary = stringResource(backgroundLabel(background)),
position = Position.Top,
onClick = { showBackgroundPicker = true },
)
if (kind == WidgetKind.TASKS) {
GroupedRow(
title = stringResource(R.string.widget_config_theme),
summary = stringResource(themeOverrideLabel(themeOverride)),
position = Position.Middle,
onClick = { showThemePicker = true },
)
GroupedRow(
title = stringResource(R.string.widget_config_row_color),
summary = stringResource(rowColorLabel(rowColor)),
position = Position.Bottom,
onClick = { showRowColorPicker = true },
)
} else {
GroupedRow(
title = stringResource(R.string.widget_config_theme),
summary = stringResource(themeOverrideLabel(themeOverride)),
position = Position.Bottom,
onClick = { showThemePicker = true },
)
}
}
if (showBackgroundPicker) {
OptionPicker(
title = stringResource(R.string.widget_config_background),
options = kind.backgrounds(),
selected = background,
label = { stringResource(backgroundLabel(it)) },
summary = { stringResource(backgroundHint(it)) },
onSelect = onBackgroundChange,
onDismiss = { showBackgroundPicker = false },
)
}
if (showThemePicker) {
OptionPicker(
title = stringResource(R.string.widget_config_theme),
options = listOf<WidgetThemeMode?>(null, WidgetThemeMode.LIGHT, WidgetThemeMode.DARK),
selected = themeOverride,
label = { stringResource(themeOverrideLabel(it)) },
onSelect = onThemeOverrideChange,
onDismiss = { showThemePicker = false },
)
}
if (showRowColorPicker) {
OptionPicker(
title = stringResource(R.string.widget_config_row_color),
options = WidgetRowColor.entries,
selected = rowColor,
label = { stringResource(rowColorLabel(it)) },
summary = { stringResource(rowColorHint(it)) },
onSelect = onRowColorChange,
onDismiss = { showRowColorPicker = false },
)
}
}
internal fun smartListLabel(list: SmartList): Int = when (list) {
SmartList.TODAY -> R.string.smart_today
SmartList.UPCOMING -> R.string.smart_upcoming
SmartList.OVERDUE -> R.string.smart_overdue
else -> R.string.smart_all
}
private fun backgroundLabel(background: WidgetBackground): Int = when (background) {
WidgetBackground.SOLID -> R.string.widget_config_background_solid
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
}
private fun backgroundHint(background: WidgetBackground): Int = when (background) {
WidgetBackground.SOLID -> R.string.widget_config_background_solid_hint
WidgetBackground.TINTED -> R.string.widget_config_background_tinted_hint
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent_hint
}
private fun themeOverrideLabel(mode: WidgetThemeMode?): Int = when (mode) {
null -> R.string.widget_config_theme_follow_app
WidgetThemeMode.LIGHT -> R.string.widget_config_theme_light
WidgetThemeMode.DARK -> R.string.widget_config_theme_dark
WidgetThemeMode.SYSTEM -> R.string.widget_config_theme_follow_app
}
private fun rowColorLabel(rowColor: WidgetRowColor): Int = when (rowColor) {
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list
}
private fun rowColorHint(rowColor: WidgetRowColor): Int = when (rowColor) {
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral_hint
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list_hint
}