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 d2e781f..46d3381 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -47,6 +48,7 @@ import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider as GlanceColorProvider import de.jeanlucmakiola.agendula.MainActivity import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.domain.TaskFilter @@ -57,7 +59,6 @@ import de.jeanlucmakiola.floret.glance.FloretGlanceTheme import de.jeanlucmakiola.floret.glance.WidgetActionButton import de.jeanlucmakiola.floret.glance.WidgetActionStyle import de.jeanlucmakiola.floret.glance.WidgetBackground -import de.jeanlucmakiola.floret.glance.WidgetGroupedRow import de.jeanlucmakiola.floret.glance.WidgetHeader import de.jeanlucmakiola.floret.glance.WidgetMessage import de.jeanlucmakiola.floret.glance.WidgetSurface @@ -70,9 +71,10 @@ import kotlin.time.Instant /** * "Tasks" home-screen widget: one smart list (Today by default) or one or more - * real lists per instance, with a tonal container per section — grouped by - * list, or by due bucket (Overdue / Today / Tomorrow / Later / No date) — and a - * check to complete each task in place. + * real lists per instance, with a Calendar-agenda look — a plain section label + * per group (by list, or by due bucket: Overdue / Today / Tomorrow / Later / + * No date) above a run of standalone, fully-rounded task pills, each with a + * trailing check to complete it in place. * * The data is collected live inside the composition rather than loaded once in * the `provideGlance` preamble: an `update` on a running session only @@ -96,6 +98,7 @@ class TaskWidget : GlanceAppWidget() { val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY)) val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY)) + val rowColor = parseWidgetRowColor(currentState(WIDGET_ROW_COLOR_KEY)) val data by remember(filter, groupByList) { context.taskWidgetData(filter, groupByList) } .collectAsState(initial = if (filter == initialFilter && groupByList == initialGroupByList) initial else null) val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready @@ -105,7 +108,7 @@ class TaskWidget : GlanceAppWidget() { dynamicColor = ready?.dynamicColor ?: true, mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM, ) { - TaskWidgetBody(filter, data, background) + TaskWidgetBody(filter, data, background, rowColor) } } } @@ -140,6 +143,7 @@ private fun TaskWidgetBody( filter: TaskFilter, data: TaskWidgetData?, background: WidgetBackground, + rowColor: WidgetRowColor, ) { val context = LocalContext.current val ready = data as? TaskWidgetData.Ready @@ -150,10 +154,10 @@ private fun TaskWidgetBody( } WidgetSurface(background = background) { Column( - modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp), + modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp), ) { TaskWidgetHeader(title, filter, ready?.singleListId) - Spacer(GlanceModifier.height(4.dp)) + Spacer(GlanceModifier.height(2.dp)) when (data) { null -> Unit TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable)) @@ -161,19 +165,47 @@ private fun TaskWidgetBody( if (data.rows.isEmpty()) { WidgetMessage(context.getString(R.string.widget_empty)) } else { - val sections = widgetSections( + val allSections = widgetSections( data.rows, data.groupByList, data.listNames, ) { kind -> context.getString(dueBucketLabel(kind)) } + // A single group repeats what the widget's own title already + // says (e.g. the "Today" smart list showing one "Today" + // bucket) — the label only earns its place once there's more + // than one group to tell apart. + val singleGroup = allSections.count { it is WidgetSection.Header } <= 1 + val sections = if (singleGroup) allSections.filterNot { it is WidgetSection.Header } else allSections LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(sections.size) { index -> when (val section = sections[index]) { is WidgetSection.Header -> SectionHeader(section.name) - is WidgetSection.TaskItem -> - WidgetGroupedRow(position = section.position) { - TaskRow(section.row, data.use24Hour) + is WidgetSection.TaskItem -> { + val container = if (rowColor == WidgetRowColor.BY_LIST) { + ColorProvider( + day = pastelize(section.row.color, false), + night = pastelize(section.row.color, true), + ) + } else { + GlanceTheme.colors.surfaceVariant } + // A LazyColumn item's host view isn't a linear layout on + // its own, so the row and its gap need an explicit Column + // to stack rather than overlap. + Column { + AgendaTaskRow( + row = section.row, + use24Hour = data.use24Hour, + color = container, + colored = rowColor == WidgetRowColor.BY_LIST, + // The due-bucket header above already names the day + // (Today / Tomorrow); grouped by list, there's no + // such header, so the row needs to say it itself. + suppressDayLabel = !data.groupByList, + ) + Spacer(GlanceModifier.height(8.dp)) + } + } } } } @@ -224,32 +256,49 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), contentDescription = context.getString(R.string.new_task), action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)), - style = WidgetActionStyle.TONAL, + style = WidgetActionStyle.FILLED, + size = 36.dp, + height = 28.dp, ) } } +/** + * A Calendar-agenda-style task chip: its own fully-rounded pill (list colour + * or a neutral tone, per [WidgetRowColor]), a leading check to complete it, + * title and due to its right. [colored] pills use fixed light text/icon — + * [pastelize][de.jeanlucmakiola.floret.components.pastelize] keeps them + * mid-bright rather than pale, so the theme's own `onSurface` (tuned for the + * neutral tonal fill) reads too dark against them. + */ @Composable -private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) { +private fun AgendaTaskRow( + row: WidgetTaskRow, + use24Hour: Boolean, + color: GlanceColorProvider, + colored: Boolean, + suppressDayLabel: Boolean, +) { 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()), ) + 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 + val onColorError = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.error Row( - modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 4.dp), + modifier = GlanceModifier + .fillMaxWidth() + .cornerRadius(16.dp) + .background(color) + .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = GlanceModifier - .width(4.dp) - .height(28.dp) - .cornerRadius(2.dp) - .background(ColorProvider(day = pastelize(row.color, false), night = pastelize(row.color, true))), - ) {} - Box( - modifier = GlanceModifier - .size(48.dp) + .size(28.dp) + .cornerRadius(14.dp) .clickable( actionRunCallback( actionParametersOf( @@ -264,26 +313,23 @@ private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) { Image( provider = ImageProvider(GlanceR.drawable.floret_ic_widget_check), contentDescription = context.getString(R.string.widget_complete_task, title), - colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(22.dp), + colorFilter = ColorFilter.tint(onColorVariant), + modifier = GlanceModifier.size(16.dp), ) } - Column( - modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp) - .clickable(open), - ) { + Spacer(GlanceModifier.width(8.dp)) + Column(modifier = GlanceModifier.defaultWeight().clickable(open)) { Text( text = title, maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp), + style = TextStyle(color = onColor, fontSize = 14.sp, fontWeight = FontWeight.Medium), ) - dueText(context, row, use24Hour)?.let { due -> + dueText(context, row, use24Hour, suppressDayLabel)?.let { due -> Text( text = due, maxLines = 1, style = TextStyle( - color = if (row.isOverdue) GlanceTheme.colors.error - else GlanceTheme.colors.onSurfaceVariant, + color = if (row.isOverdue) onColorError else onColorVariant, fontSize = 12.sp, ), ) @@ -292,13 +338,21 @@ private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) { } } -/** "Today · 14:00", "Tomorrow", or the compact date; `null` without a due date. */ -private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String? { +/** + * The chip's own due line — bare "14:00" when [suppressDayLabel] (the due-bucket + * header above already says "Today"/"Tomorrow", Calendar-agenda style: the day + * is the group, the chip only needs the time), otherwise "Today · 14:00" / + * "Tomorrow", or the compact date for anything else. `null` without a due date, + * or for an all-day task once its day is already said by the header. + */ +private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean, suppressDayLabel: Boolean): String? { val due = row.due ?: return null val relative = when (row.dueKind) { DueKind.TODAY -> context.getString(R.string.home_due_today) DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow) else -> null } ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour) - return if (row.isAllDay) relative else "$relative · ${due.formatTime(use24Hour)}" + if (row.isAllDay) return if (suppressDayLabel) null else relative + val time = due.formatTime(use24Hour) + return if (suppressDayLabel) time else "$relative · $time" } 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 a82474e..99cb4b6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt @@ -91,12 +91,14 @@ class TaskWidgetConfigActivity : ComponentActivity() { var groupByList by remember { mutableStateOf(false) } var background by remember { mutableStateOf(WidgetBackground.SOLID) } var themeOverride by remember { mutableStateOf(null) } + var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) } 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]) themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY]) + rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY]) } val dark = when (settings.themeMode) { ThemeMode.SYSTEM -> isSystemInDarkTheme() @@ -115,6 +117,8 @@ class TaskWidgetConfigActivity : ComponentActivity() { onBackgroundChange = { background = it }, themeOverride = themeOverride, onThemeOverrideChange = { themeOverride = it }, + rowColor = rowColor, + onRowColorChange = { rowColor = it }, onSave = { filter -> lifecycleScope.launch { updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) { @@ -123,6 +127,7 @@ class TaskWidgetConfigActivity : ComponentActivity() { it[WIDGET_BACKGROUND_KEY] = background.name themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name } ?: it.remove(WIDGET_THEME_KEY) + it[WIDGET_ROW_COLOR_KEY] = rowColor.name } TaskWidget().update(this@TaskWidgetConfigActivity, glanceId) setResult(RESULT_OK, resultIntent(appWidgetId)) @@ -150,11 +155,14 @@ private fun WidgetFilterPicker( onBackgroundChange: (WidgetBackground) -> Unit, themeOverride: WidgetThemeMode?, onThemeOverrideChange: (WidgetThemeMode?) -> Unit, + rowColor: WidgetRowColor, + onRowColorChange: (WidgetRowColor) -> 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 and real lists are mutually exclusive; only real lists support // picking more than one. `selectedLists` empty + `selectedSmart` non-null is // a smart-list pick; the reverse (or both empty, before the stored filter @@ -232,9 +240,15 @@ private fun WidgetFilterPicker( GroupedRow( title = stringResource(R.string.widget_config_theme), summary = stringResource(themeOverrideLabel(themeOverride)), - position = Position.Bottom, + 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 }, + ) } if (showBackgroundPicker) { @@ -258,6 +272,17 @@ private fun WidgetFilterPicker( 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) { @@ -285,3 +310,13 @@ private fun themeOverrideLabel(mode: WidgetThemeMode?): Int = when (mode) { 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 +} 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 a8f69c9..b2b4139 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt @@ -35,8 +35,17 @@ internal val WIDGET_BACKGROUND_KEY = stringPreferencesKey("widget_background") /** Per-instance Glance state key holding a [WidgetThemeMode] override; unset = follow the app's own theme setting. */ internal val WIDGET_THEME_KEY = stringPreferencesKey("widget_theme") +/** Per-instance Glance state key holding the widget's [WidgetRowColor] ([name]); unset = [WidgetRowColor.NEUTRAL]. */ +internal val WIDGET_ROW_COLOR_KEY = stringPreferencesKey("widget_row_color") + internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY) +/** How a row's tonal container is tinted: the neutral surface role, or the task's own list colour. */ +internal enum class WidgetRowColor { NEUTRAL, BY_LIST } + +internal fun parseWidgetRowColor(raw: String?): WidgetRowColor = + raw?.let { runCatching { WidgetRowColor.valueOf(it) }.getOrNull() } ?: WidgetRowColor.NEUTRAL + internal fun parseWidgetBackground(raw: String?): WidgetBackground = raw?.let { runCatching { WidgetBackground.valueOf(it) }.getOrNull() } ?: WidgetBackground.SOLID diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cfdff58..dea0348 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -776,6 +776,11 @@ Follow app Light Dark + Row colour + Neutral + Every row uses the same tonal fill + By list + Each row is tinted with its list\'s own colour At a glance Your next task, or a quick way to add one Nothing due today 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 2156df3..a79b8f6 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt @@ -217,6 +217,13 @@ class WidgetDataTest { assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(WidgetThemeMode.LIGHT)).isEqualTo(WidgetThemeMode.LIGHT) } + @Test + fun `an unset row colour is Neutral, and round-trips`() { + assertThat(parseWidgetRowColor(null)).isEqualTo(WidgetRowColor.NEUTRAL) + WidgetRowColor.entries.forEach { assertThat(parseWidgetRowColor(it.name)).isEqualTo(it) } + assertThat(parseWidgetRowColor("garbage")).isEqualTo(WidgetRowColor.NEUTRAL) + } + @Test fun `overdue count only counts overdue rows`() { val ready = readyWithTheme(ThemeMode.SYSTEM).copy( diff --git a/floret-kit b/floret-kit index 836d8e6..435447e 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 836d8e6be0ed5cd44f5f550dc127247ee44dd601 +Subproject commit 435447eae266b4460038f8db2b9557303343fe4d