diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt index 5180dcc..c15475e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt @@ -6,7 +6,7 @@ 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.dp import androidx.compose.ui.unit.sp import androidx.glance.GlanceId @@ -17,8 +17,6 @@ import androidx.glance.LocalSize import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionStartActivity -import androidx.glance.appwidget.lazy.LazyColumn -import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.color.ColorProvider import androidx.glance.currentState @@ -51,6 +49,8 @@ import de.jeanlucmakiola.floret.glance.WidgetThemeMode import de.jeanlucmakiola.floret.glance.WidgetTile import de.jeanlucmakiola.floret.glance.R as GlanceR import kotlinx.coroutines.flow.first +import kotlin.math.max +import kotlin.math.min /** * "Lists" home-screen widget: one shortcut tile per list, in the list's own @@ -61,7 +61,9 @@ class ListsWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition - override val sizeMode = SizeMode.Responsive(setOf(CompactSize, WideSize)) + // Exact, not Responsive: the grid is sized to the real placement so every + // tile fits without scrolling. + override val sizeMode = SizeMode.Exact override suspend fun provideGlance(context: Context, id: GlanceId) { val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable) @@ -81,11 +83,6 @@ class ListsWidget : GlanceAppWidget() { } } } - - private companion object { - val CompactSize = DpSize(110.dp, 110.dp) - val WideSize = DpSize(250.dp, 180.dp) - } } @Composable @@ -93,20 +90,22 @@ private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground val context = LocalContext.current val ready = data as? WidgetStatsData.Ready val tiles = ready?.snapshot?.lists?.let { lists -> listIds?.let { ids -> lists.filter { it.id in ids } } ?: lists } - val columns = if (LocalSize.current.width < 250.dp) 2 else 3 + val size = LocalSize.current WidgetSurface(background = background) { - Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) { + Column(modifier = GlanceModifier.fillMaxSize().padding(Padding)) { when { data == null -> Unit ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable)) tiles.isNullOrEmpty() -> WidgetMessage(context.getString(R.string.widget_lists_empty)) else -> { - val rows = tiles.chunked(columns) - LazyColumn(modifier = GlanceModifier.fillMaxSize()) { - items(rows.size) { index -> - Column { - ListTileRow(rows[index], columns) - Spacer(GlanceModifier.height(8.dp)) + val grid = listGrid(tiles.size, size.width - Padding * 2, size.height - Padding * 2) + val rows = tiles.take(grid.columns * grid.rows).chunked(grid.columns) + rows.forEachIndexed { index, rowTiles -> + if (index > 0) Spacer(GlanceModifier.height(Gap)) + Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) { + rowTiles.forEachIndexed { i, tile -> + if (i > 0) Spacer(GlanceModifier.width(Gap)) + ListTileCell(tile, grid, GlanceModifier.defaultWeight()) } } } @@ -116,59 +115,114 @@ private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground } } +internal data class ListGrid(val columns: Int, val rows: Int, val tileWidth: Dp, val tileHeight: Dp) + +/** + * Picks the column count whose tiles come closest to [TileAspect] at the + * largest size, among those that fit [count] tiles into [width] x [height]. + * Tiles too narrow for the one-line layout must be tall enough to stack, so a + * narrow widget shows fewer, readable tiles. Lists that don't fit are dropped + * rather than scrolled. + */ +internal fun listGrid(count: Int, width: Dp, height: Dp): ListGrid { + fun span(total: Dp, cells: Int) = (total - Gap * (cells - 1)) / cells + fun maxRows(columns: Int): Int { + val minHeight = if (span(width, columns) < MinOneLineTileWidth) MinNarrowTileHeight else MinTileHeight + return max(1, ((height + Gap) / (minHeight + Gap)).toInt()) + } + val maxColumns = max(1, ((width + Gap) / (MinTileWidth + Gap)).toInt()) + val best = (1..min(count, maxColumns)) + .map { columns -> columns to (count + columns - 1) / columns } + .filter { (columns, rows) -> rows <= maxRows(columns) } + .maxByOrNull { (columns, rows) -> min(span(width, columns).value / TileAspect, span(height, rows).value) } + val (columns, rows) = best ?: (maxColumns to maxRows(maxColumns)) + return ListGrid(columns, rows, span(width, columns), span(height, rows)) +} + @Composable -private fun ListTileRow(rowTiles: List, columns: Int) { - Row(modifier = GlanceModifier.fillMaxWidth()) { - rowTiles.forEachIndexed { index, tile -> - if (index > 0) Spacer(GlanceModifier.width(8.dp)) - ListTileCell(tile, GlanceModifier.defaultWeight()) - } - repeat(columns - rowTiles.size) { - Spacer(GlanceModifier.width(8.dp)) - Spacer(GlanceModifier.defaultWeight()) +private fun ListTileCell(tile: ListTile, grid: ListGrid, modifier: GlanceModifier) { + val context = LocalContext.current + val tint = ColorProvider(day = pastelize(tile.color, false), night = pastelize(tile.color, true)) + val onTint = GlanceColorProvider(Color.White) + val showAdd = tile.acceptsWrites && grid.tileWidth >= MinTileWidthWithAdd + WidgetTile( + color = tint, + modifier = modifier, + onClick = actionStartActivity(MainActivity.listIntent(context, tile.id)), + ) { + if (grid.tileWidth < MinOneLineTileWidth && grid.tileHeight >= MinNarrowTileHeight) { + // Narrow: the name gets the full width and wraps; count and + share the bottom row. + Column(modifier = GlanceModifier.fillMaxSize()) { + Text( + text = tile.name, + maxLines = if (grid.tileHeight >= MinTwoLineNameHeight) 2 else 1, + style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium), + ) + Spacer(GlanceModifier.defaultWeight()) + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${tile.openCount}", + modifier = GlanceModifier.defaultWeight(), + style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium), + ) + if (showAdd) ListAddButton(tile) + } + } + } else if (grid.tileHeight >= MinStackedTileHeight) { + Column(modifier = GlanceModifier.fillMaxSize()) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = tile.name, + maxLines = 1, + modifier = GlanceModifier.defaultWeight(), + style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium), + ) + if (showAdd) ListAddButton(tile) + } + Spacer(GlanceModifier.defaultWeight()) + Text( + text = "${tile.openCount}", + style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium), + ) + } + } else { + Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = tile.name, + maxLines = 1, + modifier = GlanceModifier.defaultWeight(), + style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium), + ) + Text( + text = "${tile.openCount}", + modifier = GlanceModifier.padding(horizontal = 4.dp), + style = TextStyle(color = onTint, fontSize = 15.sp, fontWeight = FontWeight.Bold), + ) + if (showAdd) ListAddButton(tile) + } } } } @Composable -private fun ListTileCell(tile: ListTile, modifier: GlanceModifier) { +private fun ListAddButton(tile: ListTile) { val context = LocalContext.current - val tint = ColorProvider(day = pastelize(tile.color, false), night = pastelize(tile.color, true)) - WidgetTile( - color = tint, - modifier = modifier.height(72.dp), - onClick = actionStartActivity(MainActivity.listIntent(context, tile.id)), - ) { - Column(modifier = GlanceModifier.fillMaxSize()) { - Row( - modifier = GlanceModifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = tile.name, - maxLines = 1, - modifier = GlanceModifier.defaultWeight(), - style = TextStyle(color = GlanceColorProvider(Color.White), fontSize = 13.sp, fontWeight = FontWeight.Medium), - ) - if (tile.acceptsWrites) { - WidgetActionButton( - icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), - contentDescription = context.getString(R.string.new_task), - action = actionStartActivity(MainActivity.newTaskIntent(context, tile.id)), - style = WidgetActionStyle.TONAL, - size = 20.dp, - ) - } - } - Spacer(GlanceModifier.defaultWeight()) - Text( - text = "${tile.openCount}", - style = TextStyle( - color = GlanceColorProvider(Color.White), - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - ), - ) - } - } + WidgetActionButton( + icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), + contentDescription = context.getString(R.string.new_task), + action = actionStartActivity(MainActivity.newTaskIntent(context, tile.id)), + style = WidgetActionStyle.TONAL, + size = 20.dp, + ) } + +private val Padding = 8.dp +private val Gap = 8.dp +private val MinTileWidth = 72.dp +private val MinTileHeight = 36.dp +private val MinTileWidthWithAdd = 64.dp +private val MinStackedTileHeight = 60.dp +private val MinNarrowTileHeight = 52.dp +private val MinTwoLineNameHeight = 70.dp +private val MinOneLineTileWidth = 120.dp +private const val TileAspect = 1.5f 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 9ae4119..f14b728 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -43,6 +43,7 @@ import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.text.FontWeight import androidx.glance.text.Text +import androidx.glance.text.TextDecoration import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider as GlanceColorProvider import de.jeanlucmakiola.agendula.MainActivity @@ -54,18 +55,18 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.pastelize 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.WidgetCheckButton import de.jeanlucmakiola.floret.glance.WidgetGroupedRow import de.jeanlucmakiola.floret.glance.WidgetHeader +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction import de.jeanlucmakiola.floret.glance.WidgetMessage import de.jeanlucmakiola.floret.glance.WidgetSurface import de.jeanlucmakiola.floret.glance.WidgetThemeMode import de.jeanlucmakiola.floret.time.formatDateTimeCompact import de.jeanlucmakiola.floret.time.formatTime import de.jeanlucmakiola.floret.glance.R as GlanceR +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlin.time.Instant @@ -123,24 +124,36 @@ class TaskWidget : GlanceAppWidget() { class CompleteTaskAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { val taskId = parameters[TaskIdKey] ?: return - val occurrence = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE } - ?.let { Instant.fromEpochMilliseconds(it) } - runCatching { - context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true) - } + val occurrenceMillis = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE } + val key = WidgetCompletions.key(taskId, occurrenceMillis) + if (!WidgetCompletions.start(key)) return + val app = context.applicationContext // This callback is shared by every widget's rows, and glanceId only // tells us the tapped widget's instance, not which provider class it // belongs to — updating the wrong one by id would push this class's // RemoteViews into another widget's AppWidget slot. updateAll is // class-scoped, so redrawing every widget is the immediate, correct // option; it also covers every other open instance showing this task. - updateAllWidgets(context.applicationContext) + // First the ticked frame, then the real completion a beat later. + updateAllWidgets(app) + delay(TICK_FRAME_MILLIS) + runCatching { + context.widgetEntryPoint().tasksRepository() + .setCompleted(taskId, occurrenceMillis?.let { Instant.fromEpochMilliseconds(it) }, true) + } + updateAllWidgets(app) + // Hold the ticked look until the store's change has redrawn the row away. + delay(TICK_RELEASE_MILLIS) + WidgetCompletions.finish(key) + updateAllWidgets(app) } companion object { val TaskIdKey = ActionParameters.Key("taskId") val OccurrenceKey = ActionParameters.Key("occurrenceStart") const val NO_OCCURRENCE = Long.MIN_VALUE + private const val TICK_FRAME_MILLIS = 600L + private const val TICK_RELEASE_MILLIS = 1500L } } @@ -270,13 +283,10 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo title = title, titleAction = actionStartActivity(open), ) { - WidgetActionButton( + WidgetHeaderAction( icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), contentDescription = context.getString(R.string.new_task), action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)), - style = WidgetActionStyle.FILLED, - size = 36.dp, - height = 28.dp, ) } } @@ -305,6 +315,7 @@ internal fun AgendaTaskRow( 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 + val completing = WidgetCompletions.isCompleting(row) WidgetGroupedRow(position = Position.Alone, color = color) { Row( modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), @@ -320,13 +331,19 @@ internal fun AgendaTaskRow( ), ), tint = onColorVariant, + checked = completing, ) Spacer(GlanceModifier.width(8.dp)) Column(modifier = GlanceModifier.defaultWeight().clickable(open)) { Text( text = title, maxLines = 1, - style = TextStyle(color = onColor, fontSize = 14.sp, fontWeight = FontWeight.Medium), + style = TextStyle( + color = if (completing) onColorVariant else onColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None, + ), ) dueText(context, row, use24Hour, suppressDayLabel)?.let { due -> Text( 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 858cbd9..ae2a009 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt @@ -87,7 +87,8 @@ private fun widgetFor(kind: WidgetKind): GlanceAppWidget = when (kind) { /** * 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; every other kind only + * [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. @@ -163,11 +164,11 @@ class TaskWidgetConfigActivity : ComponentActivity() { it[WIDGET_GROUP_BY_LIST_KEY] = groupByList it[WIDGET_ROW_COLOR_KEY] = rowColor.name } - WidgetKind.LISTS -> { + WidgetKind.LISTS, WidgetKind.UP_NEXT -> { selectedListIds?.let { ids -> it[WIDGET_LIST_IDS_KEY] = ids.widgetListIdsStorageValue() } ?: it.remove(WIDGET_LIST_IDS_KEY) } - WidgetKind.TODAY, WidgetKind.UP_NEXT, WidgetKind.WEEK -> Unit + WidgetKind.TODAY, WidgetKind.WEEK -> Unit } it[WIDGET_BACKGROUND_KEY] = background.name themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name } @@ -302,6 +303,29 @@ private fun WidgetFilterPicker( ) } 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), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt index 1020c50..d7d1f97 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt @@ -5,7 +5,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.unit.DpSize +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.glance.GlanceId @@ -18,16 +18,21 @@ import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.currentState import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column +import androidx.glance.layout.Row import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.padding +import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.text.FontWeight import androidx.glance.text.Text @@ -38,10 +43,9 @@ import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback 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.WidgetHeader +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction import de.jeanlucmakiola.floret.glance.WidgetMessage import de.jeanlucmakiola.floret.glance.WidgetProgressRing import de.jeanlucmakiola.floret.glance.WidgetSurface @@ -51,15 +55,15 @@ import kotlinx.coroutines.flow.first /** * "Today" home-screen widget: how far through today you are, as a ring — - * done ÷ (done + still open today/overdue) — with the next open task shown - * below it once the widget is tall enough to carry one. Complements - * [TaskWidget] rather than repeating it: a glance stat, not another list. + * done ÷ (done + still open today/overdue) — with today's open tasks listed + * below (or beside, when wide) once the widget has room for them. */ class TodayWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition - override val sizeMode = SizeMode.Responsive(setOf(CompactSize, TallSize)) + // Exact, so the ring can grow with the real placement rather than a breakpoint. + override val sizeMode = SizeMode.Exact override suspend fun provideGlance(context: Context, id: GlanceId) { val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable) @@ -78,54 +82,67 @@ class TodayWidget : GlanceAppWidget() { } } } - - private companion object { - val CompactSize = DpSize(110.dp, 110.dp) - val TallSize = DpSize(110.dp, 180.dp) - } } @Composable private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground) { val context = LocalContext.current val ready = data as? WidgetStatsData.Ready - val tall = LocalSize.current.height >= 150.dp + val size = LocalSize.current + val contentWidth = size.width - Padding * 2 + val contentHeight = size.height - Padding * 2 - HeaderHeight WidgetSurface(background = background) { - Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) { + Column(modifier = GlanceModifier.fillMaxSize().padding(Padding)) { WidgetHeader( title = context.getString(R.string.smart_today), titleAction = actionStartActivity(MainActivity.todayIntent(context)), ) { - WidgetActionButton( + WidgetHeaderAction( icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), contentDescription = context.getString(R.string.new_task), action = actionStartActivity(MainActivity.newTaskIntent(context)), - style = WidgetActionStyle.FILLED, - size = 32.dp, ) } - Spacer(GlanceModifier.height(4.dp)) + Spacer(GlanceModifier.height(HeaderGap)) when { data == null -> Unit ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable)) else -> { - val ringAreaModifier = GlanceModifier.fillMaxWidth() - .let { if (tall) it.defaultWeight() else it } - .clickable(actionStartActivity(MainActivity.todayIntent(context))) - Box(modifier = ringAreaModifier, contentAlignment = Alignment.Center) { - TodayRing(ready) - } - if (tall) { - Spacer(GlanceModifier.height(6.dp)) - val next = ready.snapshot.todayNext - if (next != null) { - AgendaTaskRow( - row = next, - use24Hour = ready.snapshot.use24Hour, - color = GlanceTheme.colors.surfaceVariant, - colored = false, - suppressDayLabel = true, - ) + val tasks = ready.snapshot.todayTasks + val openToday = actionStartActivity(MainActivity.todayIntent(context)) + when { + tasks.isNotEmpty() && contentWidth >= MinSideBySideWidth -> { + val ring = minOf(contentHeight, contentWidth * 0.4f, MaxRing) + Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = GlanceModifier.width(ring).fillMaxHeight().clickable(openToday), + contentAlignment = Alignment.Center, + ) { + TodayRing(ready, ring) + } + Spacer(GlanceModifier.width(12.dp)) + TodayTaskList(ready, GlanceModifier.defaultWeight().fillMaxHeight()) + } + } + tasks.isNotEmpty() && contentHeight >= MinRing + ListGap + RowHeight -> { + val ring = minOf(contentWidth, MaxRing, maxOf(MinRing, contentHeight - ListGap - RowHeight * 2)) + Box( + modifier = GlanceModifier.fillMaxWidth().height(ring).clickable(openToday), + contentAlignment = Alignment.Center, + ) { + TodayRing(ready, ring) + } + Spacer(GlanceModifier.height(ListGap)) + TodayTaskList(ready, GlanceModifier.fillMaxWidth().defaultWeight()) + } + else -> { + val ring = minOf(contentWidth, contentHeight, MaxRing) + Box( + modifier = GlanceModifier.fillMaxSize().clickable(openToday), + contentAlignment = Alignment.Center, + ) { + TodayRing(ready, ring) + } } } } @@ -135,27 +152,50 @@ private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground } @Composable -private fun TodayRing(ready: WidgetStatsData.Ready) { +private fun TodayTaskList(ready: WidgetStatsData.Ready, modifier: GlanceModifier) { + val tasks = ready.snapshot.todayTasks + LazyColumn(modifier = modifier) { + items(tasks.size) { index -> + // 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. + Column { + AgendaTaskRow( + row = tasks[index], + use24Hour = ready.snapshot.use24Hour, + color = GlanceTheme.colors.surfaceVariant, + colored = false, + suppressDayLabel = true, + ) + if (index < tasks.lastIndex) Spacer(GlanceModifier.height(6.dp)) + } + } + } +} + +@Composable +private fun TodayRing(ready: WidgetStatsData.Ready, ringSize: Dp) { val context = LocalContext.current val snapshot = ready.snapshot val total = snapshot.todayDone + snapshot.todayRemaining val allDone = total > 0 && snapshot.todayRemaining == 0 val progress = if (total == 0) 0f else snapshot.todayDone.toFloat() / total val indicator = if (allDone) GlanceTheme.colors.tertiary else GlanceTheme.colors.primary + // Label sizes scale with the ring so a large ring doesn't hold a tiny label. + val scale = (ringSize / MinRing).coerceIn(1f, 2f) WidgetProgressRing( - progress = if (total == 0) 0f else progress, - size = 72.dp, + progress = progress, + size = ringSize, trackColor = GlanceTheme.colors.surfaceVariant, indicatorColor = indicator, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = GlanceModifier.padding(horizontal = ringSize * 0.15f)) { if (total == 0) { Text( text = context.getString(R.string.widget_today_nothing_due), maxLines = 2, style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, - fontSize = 11.sp, + fontSize = (11 * scale).sp, textAlign = TextAlign.Center, ), ) @@ -165,7 +205,7 @@ private fun TodayRing(ready: WidgetStatsData.Ready) { maxLines = 2, style = TextStyle( color = GlanceTheme.colors.tertiary, - fontSize = 13.sp, + fontSize = (13 * scale).sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, ), @@ -175,7 +215,7 @@ private fun TodayRing(ready: WidgetStatsData.Ready) { text = "${snapshot.todayDone}/$total", style = TextStyle( color = GlanceTheme.colors.onSurface, - fontSize = 18.sp, + fontSize = (18 * scale).sp, fontWeight = FontWeight.Medium, ), ) @@ -183,3 +223,12 @@ private fun TodayRing(ready: WidgetStatsData.Ready) { } } } + +private val Padding = 8.dp +private val HeaderHeight = 36.dp +private val HeaderGap = 4.dp +private val ListGap = 8.dp +private val RowHeight = 48.dp +private val MinRing = 72.dp +private val MaxRing = 160.dp +private val MinSideBySideWidth = 230.dp 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 88e4747..86ab2fa 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt @@ -1,11 +1,12 @@ package de.jeanlucmakiola.agendula.widget import android.content.Context +import android.text.format.DateFormat import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.glance.GlanceId @@ -13,59 +14,78 @@ import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme import androidx.glance.ImageProvider import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode -import androidx.glance.action.actionParametersOf import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.color.ColorProvider import androidx.glance.currentState import androidx.glance.layout.Alignment +import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.Row import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height import androidx.glance.layout.padding +import androidx.glance.layout.size import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.text.FontWeight import androidx.glance.text.Text +import androidx.glance.text.TextDecoration import androidx.glance.text.TextStyle import de.jeanlucmakiola.agendula.MainActivity import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback +import de.jeanlucmakiola.floret.components.pastelize 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.WidgetCheckButton +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction import de.jeanlucmakiola.floret.glance.WidgetMessage +import de.jeanlucmakiola.floret.glance.WidgetShape +import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton import de.jeanlucmakiola.floret.glance.WidgetSurface import de.jeanlucmakiola.floret.glance.WidgetThemeMode import de.jeanlucmakiola.floret.time.formatTime import de.jeanlucmakiola.floret.glance.R as GlanceR import kotlinx.coroutines.flow.first +import java.time.ZoneId +import java.time.format.DateTimeFormatter import kotlin.time.Clock import kotlin.time.Instant +import kotlin.time.toJavaInstant /** * "Up next" home-screen widget: the single open task with the nearest due * moment, big, with a check to complete it in place — a countdown rather - * than a list, for someone who wants to see one thing at a time. + * than a list, for someone who wants to see one thing at a time. Optionally + * narrowed to one list; when tall, the tasks after it are listed below. */ class UpNextWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition - override val sizeMode = SizeMode.Responsive(setOf(CompactSize, WideSize)) + override val sizeMode = SizeMode.Exact override suspend fun provideGlance(context: Context, id: GlanceId) { val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable) provideContent { val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY)) val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY)) + val listId = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))?.singleOrNull() val data by remember { context.widgetStats() }.collectAsState(initial = initial) val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready FloretGlanceTheme( @@ -74,100 +94,290 @@ class UpNextWidget : GlanceAppWidget() { dynamicColor = ready?.snapshot?.dynamicColor ?: true, mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM, ) { - UpNextWidgetBody(data, background) + UpNextWidgetBody(data, background, listId) } } } - - private companion object { - val CompactSize = DpSize(110.dp, 56.dp) - val WideSize = DpSize(250.dp, 56.dp) - } } @Composable -private fun UpNextWidgetBody(data: WidgetStatsData?, background: WidgetBackground) { +private fun UpNextWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listId: Long?) { val context = LocalContext.current val ready = data as? WidgetStatsData.Ready - val row = ready?.snapshot?.nextDue + val size = LocalSize.current + val rows = ready?.snapshot?.upcoming?.let { all -> listId?.let { id -> all.filter { it.listId == id } } ?: all } + val list = listId?.let { id -> ready?.snapshot?.lists?.firstOrNull { it.id == id } } WidgetSurface(background = background) { when { data == null -> Unit ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp)) - row == null -> UpNextEmpty(context) - else -> UpNextRow(row, ready.snapshot.use24Hour) + rows.isNullOrEmpty() -> UpNextEmpty(listId) + size.height >= MinTallHeight -> UpNextTall(rows, ready.snapshot, list, size.height) + else -> UpNextCompact(rows.first(), ready.snapshot, size.width, size.height) } } } @Composable -private fun UpNextEmpty(context: Context) { +private fun UpNextEmpty(listId: Long?) { + val context = LocalContext.current Row(modifier = GlanceModifier.fillMaxSize().padding(8.dp), verticalAlignment = Alignment.CenterVertically) { WidgetMessage(context.getString(R.string.widget_up_next_empty), GlanceModifier.defaultWeight()) Spacer(GlanceModifier.width(8.dp)) WidgetActionButton( icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), contentDescription = context.getString(R.string.new_task), - action = actionStartActivity(MainActivity.newTaskIntent(context)), + action = actionStartActivity(MainActivity.newTaskIntent(context, listId)), style = WidgetActionStyle.FILLED, size = 36.dp, ) } } +/** One row tall: a cookie-shaped check leading, then the time as the hero and the title under it. */ @Composable -private fun UpNextRow(row: WidgetTaskRow, use24Hour: Boolean) { +private fun UpNextCompact(row: WidgetTaskRow, snapshot: WidgetStatsSnapshot, width: Dp, height: Dp) { val context = LocalContext.current val title = row.title.ifBlank { context.getString(R.string.task_untitled) } val now = Clock.System.now() + val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary + val checkSize = (height * 0.45f).coerceIn(32.dp, 44.dp) + val heroSize = (height.value * 0.25f).coerceIn(16f, 22f) Row( - modifier = GlanceModifier.fillMaxSize().padding(8.dp) - .clickable( - actionStartActivity( - MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()), - ), - ), + modifier = GlanceModifier.fillMaxSize().padding(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - WidgetCheckButton( - contentDescription = context.getString(R.string.widget_complete_task, title), - action = actionRunCallback( - actionParametersOf( - CompleteTaskAction.TaskIdKey to row.taskId, - CompleteTaskAction.OccurrenceKey to - (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE), - ), - ), - tint = GlanceTheme.colors.onSurfaceVariant, - ) - Spacer(GlanceModifier.width(8.dp)) - Column(modifier = GlanceModifier.defaultWeight()) { + CompleteButton(row, title, checkSize) + Spacer(GlanceModifier.width(12.dp)) + Column(modifier = GlanceModifier.defaultWeight().clickable(openTask(context, row))) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = upNextHero(context, row, snapshot.use24Hour), + maxLines = 1, + style = TextStyle(color = accent, fontSize = heroSize.sp, fontWeight = FontWeight.Bold), + ) + if (width >= MinRelativeInlineWidth) { + Spacer(GlanceModifier.width(8.dp)) + RowPill(row, upNextLabel(context, row, now)) + } + } Text( text = title, - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium), - ) - Text( - text = upNextLabel(context, row, use24Hour, now), - maxLines = 1, - style = TextStyle( - color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant, - fontSize = 12.sp, - ), + maxLines = if (height >= 88.dp) 2 else 1, + style = titleStyle(row, 15f), ) } } } /** - * The hero line under [row]'s title: a bare time when due today, "Tomorrow", - * or a coarse relative count otherwise — this widget only redraws on data - * changes, at midnight, and on the half-hourly `updatePeriodMillis`, so a - * minute-level countdown would just look wrong between redraws. + * Tall: the list as a caption with the add pill, then a big cookie check + * beside a display-size time and the title, and — with room to spare — + * what comes after it. */ -internal fun upNextLabel(context: Context, row: WidgetTaskRow, use24Hour: Boolean, now: Instant): String { +@Composable +private fun UpNextTall(rows: List, snapshot: WidgetStatsSnapshot, list: ListTile?, height: Dp) { + val context = LocalContext.current + val row = rows.first() + val title = row.title.ifBlank { context.getString(R.string.task_untitled) } + val now = Clock.System.now() + val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary + val heroSize = (height.value * 0.15f).coerceIn(26f, 38f) + val checkSize = (height * 0.2f).coerceIn(44.dp, 56.dp) + val laterCount = ((height - MinTallHeight - 40.dp) / LaterRowHeight).toInt().coerceIn(0, rows.size - 1) + Column(modifier = GlanceModifier.fillMaxSize().padding(12.dp)) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + ListDot(row.color) + Spacer(GlanceModifier.width(6.dp)) + Text( + text = list?.name ?: context.getString(R.string.widget_up_next_label), + maxLines = 1, + modifier = GlanceModifier.defaultWeight(), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Bold), + ) + WidgetHeaderAction( + icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), + contentDescription = context.getString(R.string.new_task), + action = actionStartActivity(MainActivity.newTaskIntent(context, list?.id)), + ) + } + Row( + modifier = GlanceModifier.fillMaxWidth().defaultWeight(), + verticalAlignment = Alignment.CenterVertically, + ) { + CompleteButton(row, title, checkSize) + Spacer(GlanceModifier.width(12.dp)) + Column(modifier = GlanceModifier.defaultWeight().clickable(openTask(context, row))) { + Text( + text = upNextHero(context, row, snapshot.use24Hour), + maxLines = 1, + style = TextStyle(color = accent, fontSize = heroSize.sp, fontWeight = FontWeight.Bold), + ) + RowPill(row, upNextLabel(context, row, now)) + Spacer(GlanceModifier.height(6.dp)) + Text( + text = title, + maxLines = if (laterCount > 0) 2 else 3, + style = titleStyle(row, 16f), + ) + } + } + if (laterCount > 0) { + Text( + text = context.getString(R.string.widget_up_next_then), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Bold), + ) + Spacer(GlanceModifier.height(4.dp)) + rows.drop(1).take(laterCount).forEach { later -> LaterRow(later, snapshot.use24Hour) } + } + } +} + +/** The cookie check; while [WidgetCompletions] holds the row it bursts into a filled sunny shape. */ +@Composable +private fun CompleteButton(row: WidgetTaskRow, title: String, size: Dp) { + val context = LocalContext.current + val completing = WidgetCompletions.isCompleting(row) + WidgetShapedIconButton( + shape = if (completing) WidgetShape.SUNNY else WidgetShape.COOKIE, + icon = ImageProvider(GlanceR.drawable.floret_ic_widget_check), + contentDescription = context.getString(R.string.widget_complete_task, title), + action = completeTask(row), + color = when { + completing -> GlanceTheme.colors.tertiary + row.isOverdue -> GlanceTheme.colors.errorContainer + else -> GlanceTheme.colors.primaryContainer + }, + iconTint = when { + completing -> GlanceTheme.colors.onTertiary + row.isOverdue -> GlanceTheme.colors.onErrorContainer + else -> GlanceTheme.colors.onPrimaryContainer + }, + size = size, + ) +} + +@Composable +private fun RelativePill(text: String, overdue: Boolean, done: Boolean = false) { + val container = when { + done -> GlanceTheme.colors.tertiaryContainer + overdue -> GlanceTheme.colors.errorContainer + else -> GlanceTheme.colors.secondaryContainer + } + val content = when { + done -> GlanceTheme.colors.onTertiaryContainer + overdue -> GlanceTheme.colors.onErrorContainer + else -> GlanceTheme.colors.onSecondaryContainer + } + Box( + modifier = GlanceModifier.cornerRadius(10.dp) + .background(container) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = text, + maxLines = 1, + style = TextStyle(color = content, fontSize = 11.sp, fontWeight = FontWeight.Bold), + ) + } +} + +/** The relative pill's text, or "Done" while the row is being ticked off. */ +@Composable +private fun RowPill(row: WidgetTaskRow, relative: String) { + val context = LocalContext.current + if (WidgetCompletions.isCompleting(row)) { + RelativePill(context.getString(R.string.reminder_action_done), row.isOverdue, done = true) + } else if (relative.isNotEmpty()) { + RelativePill(relative, row.isOverdue) + } +} + +@Composable +private fun titleStyle(row: WidgetTaskRow, fontSize: Float): TextStyle { + val completing = WidgetCompletions.isCompleting(row) + return TextStyle( + color = if (completing) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface, + fontSize = fontSize.sp, + fontWeight = FontWeight.Medium, + textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None, + ) +} + +@Composable +private fun LaterRow(row: WidgetTaskRow, use24Hour: Boolean) { + val context = LocalContext.current + Row( + modifier = GlanceModifier.fillMaxWidth().height(LaterRowHeight).clickable(openTask(context, row)), + verticalAlignment = Alignment.CenterVertically, + ) { + ListDot(row.color) + Spacer(GlanceModifier.width(8.dp)) + Text( + text = row.title.ifBlank { context.getString(R.string.task_untitled) }, + maxLines = 1, + modifier = GlanceModifier.defaultWeight(), + style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp), + ) + Text( + text = upNextHero(context, row, use24Hour), + maxLines = 1, + style = TextStyle( + color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant, + fontSize = 12.sp, + ), + ) + } +} + +@Composable +private fun ListDot(color: Int) { + Box( + modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp) + .background(ColorProvider(day = pastelize(color, false), night = pastelize(color, true))), + ) {} +} + +private fun openTask(context: Context, row: WidgetTaskRow): Action = + actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds())) + +private fun completeTask(row: WidgetTaskRow): Action = + actionRunCallback( + actionParametersOf( + CompleteTaskAction.TaskIdKey to row.taskId, + CompleteTaskAction.OccurrenceKey to (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE), + ), + ) + +/** + * The big line: a timed task's clock time (with its weekday once it's past + * tomorrow), or an all-day task's day. + */ +internal fun upNextHero(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String { val due = row.due ?: return "" - if (row.dueKind == DueKind.TODAY && !row.isOverdue) return due.formatTime(use24Hour) + val locale = context.resources.configuration.locales[0] + val day = DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "EEEd"), locale) + .format(due.toJavaInstant().atZone(ZoneId.systemDefault())) + if (row.isAllDay) { + return when (row.dueKind) { + DueKind.TODAY -> context.getString(R.string.smart_today) + DueKind.TOMORROW -> context.getString(R.string.widget_up_next_tomorrow) + else -> day + } + } + val time = due.formatTime(use24Hour) + return if (row.dueKind == DueKind.LATER && !row.isOverdue) "$day $time" else time +} + +/** + * The relative line beside [upNextHero]: "in 3 h", "Tomorrow", "2 d overdue". + * Coarse on purpose — this widget only redraws on data changes, at midnight, + * and on the half-hourly `updatePeriodMillis`, so a minute-level countdown + * would just look wrong between redraws. + */ +internal fun upNextLabel(context: Context, row: WidgetTaskRow, now: Instant): String { + val due = row.due ?: return "" + if (row.isAllDay && !row.isOverdue) return if (row.dueKind == DueKind.LATER) daysUntil(context, due, now) else "" if (row.isOverdue) { val hours = (now - due).inWholeHours return when { @@ -184,11 +394,16 @@ internal fun upNextLabel(context: Context, row: WidgetTaskRow, use24Hour: Boolea return when { hours < 1 -> context.getString(R.string.widget_up_next_soon) hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_in_hours, hours.toInt(), hours.toInt()) - else -> { - val days = hours / HOURS_PER_DAY - context.resources.getQuantityString(R.plurals.widget_up_next_in_days, days.toInt(), days.toInt()) - } + else -> daysUntil(context, due, now) } } +private fun daysUntil(context: Context, due: Instant, now: Instant): String { + val days = ((due - now).inWholeHours / HOURS_PER_DAY).toInt().coerceAtLeast(1) + return context.resources.getQuantityString(R.plurals.widget_up_next_in_days, days, days) +} + private const val HOURS_PER_DAY = 24L +private val MinTallHeight = 150.dp +private val MinRelativeInlineWidth = 200.dp +private val LaterRowHeight = 24.dp diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt index 6abe34c..7f69ff1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt @@ -8,11 +8,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider import androidx.glance.LocalContext -import androidx.glance.LocalSize import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionStartActivity @@ -46,6 +48,7 @@ import de.jeanlucmakiola.floret.glance.WidgetMessage import de.jeanlucmakiola.floret.glance.WidgetSurface import de.jeanlucmakiola.floret.glance.WidgetThemeMode import de.jeanlucmakiola.floret.glance.WidgetTile +import de.jeanlucmakiola.floret.glance.R as GlanceR import kotlinx.coroutines.flow.first import java.time.LocalDate import java.time.format.TextStyle as JavaTextStyle @@ -90,7 +93,6 @@ class WeekWidget : GlanceAppWidget() { private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground) { val context = LocalContext.current val ready = data as? WidgetStatsData.Ready - val tall = LocalSize.current.height >= 110.dp WidgetSurface(background = background) { when { data == null -> Unit @@ -99,7 +101,7 @@ private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground) Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) { ready.snapshot.week.forEachIndexed { index, day -> if (index > 0) Spacer(GlanceModifier.width(4.dp)) - WeekDayCell(day, tall, GlanceModifier.defaultWeight()) + WeekDayCell(day, GlanceModifier.defaultWeight()) } } } @@ -108,7 +110,7 @@ private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground) } @Composable -private fun WeekDayCell(day: WeekDay, tall: Boolean, modifier: GlanceModifier) { +private fun WeekDayCell(day: WeekDay, modifier: GlanceModifier) { val context = LocalContext.current val today = isToday(day.date) val background = if (today) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.surfaceVariant @@ -126,8 +128,8 @@ private fun WeekDayCell(day: WeekDay, tall: Boolean, modifier: GlanceModifier) { Spacer(GlanceModifier.defaultWeight()) Box( modifier = GlanceModifier - .size(44.dp) - .cornerRadius(22.dp) + .size(38.dp) + .cornerRadius(19.dp) .background(badgeBackground), contentAlignment = Alignment.Center, ) { @@ -135,36 +137,34 @@ private fun WeekDayCell(day: WeekDay, tall: Boolean, modifier: GlanceModifier) { Text( text = day.date.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale), maxLines = 1, - style = TextStyle(color = onBadgeBackground, fontSize = 11.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center), + style = TextStyle(color = onBadgeBackground, fontSize = 10.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center), ) Text( text = "${day.date.dayOfMonth}", - style = TextStyle(color = onBadgeBackground, fontSize = 15.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + style = TextStyle(color = onBadgeBackground, fontSize = 13.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), ) } } - Spacer(GlanceModifier.height(4.dp)) + Spacer(GlanceModifier.height(3.dp)) // Always rendered, even at 0 — an omitted count row made every // other day's badge drift up or down relative to its neighbours. - Text( - text = "${day.count}", - style = TextStyle( - color = if (day.overdueCount > 0) GlanceTheme.colors.error else onBackground, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ), - ) - Spacer(GlanceModifier.defaultWeight()) - if (tall) { - day.first?.let { row -> - Text( - text = row.title.ifBlank { "" }, - maxLines = 1, - style = TextStyle(color = onBackground, fontSize = 10.sp, textAlign = TextAlign.Center), - ) - } + // The checklist glyph (not a single checkmark) is what reads this + // as "N tasks" rather than an unlabelled, ambiguous number. + val countColor = if (day.overdueCount > 0) GlanceTheme.colors.error else onBackground + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + provider = ImageProvider(GlanceR.drawable.floret_ic_widget_checklist), + contentDescription = null, + colorFilter = ColorFilter.tint(countColor), + modifier = GlanceModifier.size(14.dp), + ) + Spacer(GlanceModifier.width(2.dp)) + Text( + text = "${day.count}", + style = TextStyle(color = countColor, fontSize = 15.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + ) } + Spacer(GlanceModifier.defaultWeight()) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetCompletions.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetCompletions.kt new file mode 100644 index 0000000..2b93695 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetCompletions.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.agendula.widget + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * Rows ticked in a widget but not yet completed in the store. Widgets can't + * animate, so [CompleteTaskAction] shows the row as done for a beat before + * completing it — every widget drawing that row reads this to render the + * ticked frame. + */ +internal object WidgetCompletions { + private val keys = MutableStateFlow>(emptySet()) + + fun key(taskId: Long, occurrenceStart: Long?): String = "$taskId@${occurrenceStart ?: ""}" + + /** Marks [key] as completing; `false` if it already was (a double tap). */ + fun start(key: String): Boolean { + var added = false + keys.update { current -> if (key in current) current else current + key.also { added = true } } + return added + } + + fun finish(key: String) = keys.update { it - key } + + @Composable + fun isCompleting(row: WidgetTaskRow): Boolean = + key(row.taskId, row.occurrenceStart?.toEpochMilliseconds()) in keys.collectAsState().value +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt index 0a5b8c1..e048b2a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt @@ -27,8 +27,9 @@ internal data class ListTile(val id: Long, val name: String, val color: Int, val internal data class WidgetStatsSnapshot( val todayDone: Int, val todayRemaining: Int, - val todayNext: WidgetTaskRow?, - val nextDue: WidgetTaskRow?, + val todayTasks: List, + /** Open dated tasks, earliest due first — Up next shows the head, optionally narrowed to one list. */ + val upcoming: List, val week: List, val lists: List, val dynamicColor: Boolean, @@ -42,10 +43,12 @@ internal sealed interface WidgetStatsData { data class Ready(val snapshot: WidgetStatsSnapshot) : WidgetStatsData } +private const val MAX_TODAY_ROWS = 20 + /** * How far through today a set of [open] and [completed] tasks says you are: * tasks done today (by [Task.completedAt]'s local date), tasks still open that - * are overdue or due today (in [comparator] order), and the first of those. + * are overdue or due today (in [comparator] order), and those tasks as rows. */ internal fun todayProgress( open: List, @@ -54,7 +57,7 @@ internal fun todayProgress( now: Instant, today: LocalDate, zone: ZoneId, -): Triple { +): Triple> { val done = completed.count { task -> val at = task.completedAt ?: return@count false at.toLocalDate(zone) == today @@ -63,12 +66,14 @@ internal fun todayProgress( val due = task.due ?: return@filter false isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY }.sortedWith(comparator) - return Triple(done, remaining.size, remaining.firstOrNull()?.toWidgetRow(now, today, zone)) + return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) }) } -/** The open task with the earliest due moment, overdue included — `null` when nothing is dated. */ -internal fun nextDue(open: List, now: Instant, today: LocalDate, zone: ZoneId): WidgetTaskRow? = - open.filter { it.due != null }.minByOrNull { it.due!! }?.toWidgetRow(now, today, zone) +/** Open dated tasks by earliest due moment, overdue included; undated tasks are skipped. */ +internal fun upcoming(open: List, now: Instant, today: LocalDate, zone: ZoneId): List = + open.filter { it.due != null }.sortedBy { it.due!! }.take(MAX_UPCOMING_ROWS).map { it.toWidgetRow(now, today, zone) } + +private const val MAX_UPCOMING_ROWS = 200 /** * Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue @@ -128,13 +133,13 @@ internal fun Context.widgetStats(): Flow { val zone = ZoneId.systemDefault() val today = now.toLocalDate(zone) val comparator = settings.sortOrder.comparator() - val (done, remaining, next) = todayProgress(open, completed, comparator, now, today, zone) + val (done, remaining, todayTasks) = todayProgress(open, completed, comparator, now, today, zone) val data: WidgetStatsData = WidgetStatsData.Ready( WidgetStatsSnapshot( todayDone = done, todayRemaining = remaining, - todayNext = next, - nextDue = nextDue(open, now, today, zone), + todayTasks = todayTasks, + upcoming = upcoming(open, now, today, zone), week = weekDays(open, comparator, now, today, zone), lists = listTiles(open, lists), dynamicColor = settings.dynamicColor, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f671dc9..7d1a3f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -794,6 +794,8 @@ Nothing due Soon Tomorrow + Then + All lists in %1$d h in %1$d h diff --git a/app/src/main/res/xml/appwidget_info_lists.xml b/app/src/main/res/xml/appwidget_info_lists.xml index 0259b5a..acde34e 100644 --- a/app/src/main/res/xml/appwidget_info_lists.xml +++ b/app/src/main/res/xml/appwidget_info_lists.xml @@ -4,8 +4,8 @@ android:minHeight="110dp" android:targetCellWidth="2" android:targetCellHeight="2" - android:minResizeWidth="110dp" - android:minResizeHeight="110dp" + android:minResizeWidth="57dp" + android:minResizeHeight="57dp" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" android:description="@string/widget_lists_description" diff --git a/app/src/main/res/xml/appwidget_info_today.xml b/app/src/main/res/xml/appwidget_info_today.xml index 4040c9e..db701a6 100644 --- a/app/src/main/res/xml/appwidget_info_today.xml +++ b/app/src/main/res/xml/appwidget_info_today.xml @@ -6,7 +6,7 @@ android:targetCellHeight="2" android:minResizeWidth="110dp" android:minResizeHeight="110dp" - android:resizeMode="vertical" + android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" android:description="@string/widget_today_description" android:updatePeriodMillis="1800000" diff --git a/app/src/main/res/xml/appwidget_info_up_next.xml b/app/src/main/res/xml/appwidget_info_up_next.xml index a7c5984..101b93b 100644 --- a/app/src/main/res/xml/appwidget_info_up_next.xml +++ b/app/src/main/res/xml/appwidget_info_up_next.xml @@ -6,7 +6,7 @@ android:targetCellHeight="1" android:minResizeWidth="110dp" android:minResizeHeight="56dp" - android:resizeMode="horizontal" + android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" android:description="@string/widget_up_next_description" android:updatePeriodMillis="1800000" diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt index d0083a1..db25657 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt @@ -53,25 +53,25 @@ class WidgetStatsTest { testTask(id = 1, due = at("2026-07-20T11:00:00")), // overdue, closer to now testTask(id = 2, due = at("2026-07-20T07:00:00")), // more overdue, earliest ) - val (_, _, next) = todayProgress(open, emptyList(), comparator, now, today, newYork) - assertThat(next?.taskId).isEqualTo(2) + val (_, _, rows) = todayProgress(open, emptyList(), comparator, now, today, newYork) + assertThat(rows.map { it.taskId }).containsExactly(2L, 1L).inOrder() } - // --- nextDue ----------------------------------------------------------- + // --- upcoming ---------------------------------------------------------- @Test - fun `nextDue picks the earliest due task, overdue included, and skips undated ones`() { + fun `upcoming sorts by due, overdue first, and skips undated ones`() { val open = listOf( testTask(id = 1, due = null), testTask(id = 2, due = at("2026-07-21T08:00:00")), testTask(id = 3, due = at("2026-07-20T08:00:00")), // overdue, earliest ) - assertThat(nextDue(open, now, today, newYork)?.taskId).isEqualTo(3) + assertThat(upcoming(open, now, today, newYork).map { it.taskId }).containsExactly(3L, 2L).inOrder() } @Test - fun `nextDue is null when nothing is dated`() { - assertThat(nextDue(listOf(testTask(due = null)), now, today, newYork)).isNull() + fun `upcoming is empty when nothing is dated`() { + assertThat(upcoming(listOf(testTask(due = null)), now, today, newYork)).isEmpty() } // --- weekDays ------------------------------------------------------------ diff --git a/floret-kit b/floret-kit index d7010b0..8355afc 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit d7010b0b82d1e57ad3541c914393be5438a3edc9 +Subproject commit 8355afc96591b0a0214401e92fab99c1738e31e6