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:
@@ -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,
|
||||
|
||||
@@ -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 ------------------------------------------------------------
|
||||
|
||||
+1
-1
Submodule floret-kit updated: d7010b0b82...8355afc965
Reference in New Issue
Block a user