feat(widget): adaptive Lists/Today/Up next layouts and tick feedback

- Lists: grid sized to the placement, no scrolling, single-column support
- Today: larger ring, resizable both ways, today's tasks listed below
- Up next: shaped complete button, list filter, tall layout with what's next
- Week: checklist icon with the day count, no first-task peek
- Ticking a task shows a done frame before the row goes away
This commit is contained in:
2026-10-02 14:31:00 +02:00
parent 6dafecad21
commit eced226de1
14 changed files with 627 additions and 230 deletions
@@ -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<ListTile>, 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
@@ -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<Long>("taskId")
val OccurrenceKey = ActionParameters.Key<Long>("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(
@@ -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),
@@ -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
@@ -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<CompleteTaskAction>(
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<WidgetTaskRow>, 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<CompleteTaskAction>(
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
@@ -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())
}
}
}
@@ -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<Set<String>>(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
}
@@ -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<WidgetTaskRow>,
/** Open dated tasks, earliest due first — Up next shows the head, optionally narrowed to one list. */
val upcoming: List<WidgetTaskRow>,
val week: List<WeekDay>,
val lists: List<ListTile>,
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<Task>,
@@ -54,7 +57,7 @@ internal fun todayProgress(
now: Instant,
today: LocalDate,
zone: ZoneId,
): Triple<Int, Int, WidgetTaskRow?> {
): Triple<Int, Int, List<WidgetTaskRow>> {
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<Task>, 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<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WidgetTaskRow> =
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<WidgetStatsData> {
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,
+2
View File
@@ -794,6 +794,8 @@
<string name="widget_up_next_empty">Nothing due</string>
<string name="widget_up_next_soon">Soon</string>
<string name="widget_up_next_tomorrow">Tomorrow</string>
<string name="widget_up_next_then">Then</string>
<string name="widget_up_next_any_list">All lists</string>
<plurals name="widget_up_next_in_hours">
<item quantity="one">in %1$d h</item>
<item quantity="other">in %1$d h</item>
@@ -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"
@@ -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"
@@ -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"
@@ -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 ------------------------------------------------------------