diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b96a84d..71bd556 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -244,6 +244,7 @@ dependencies {
implementation(libs.floret.core.crash)
implementation(libs.floret.identity)
implementation(libs.floret.components)
+ implementation(libs.floret.glance)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 997cb36..2a0182f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -185,6 +185,58 @@
android:resource="@xml/appwidget_info_tasks" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
return NavRequest.NewTask()
+ ACTION_NEW_TASK -> return NavRequest.NewTask(
+ listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
+ )
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt
index 058eee1..304f4ab 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt
@@ -123,10 +123,11 @@ class TaskEditViewModel @Inject constructor(
val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
- // A read-only or vanished default would give the task a list it
+ // A read-only or vanished default — or a widget's preset list, if it was
+ // since deleted or turned read-only — would give the task a list it
// cannot be saved to.
val writable = lists.filter { it.acceptsWrites }
- val defaultList = presetListId
+ val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
?: writable.firstOrNull { !it.isLocal }?.id
?: writable.firstOrNull()?.id
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt
index b9f56e3..a090218 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt
@@ -52,7 +52,8 @@ fun AgendulaNavHost(
nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true }
is NavRequest.OpenAccount ->
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
- is NavRequest.NewTask -> nav.navigate(Dest.TaskEdit.buildNew(title = navRequest.title))
+ is NavRequest.NewTask ->
+ nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
launchSingleTop = true
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt
index d4c94d9..41cb335 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt
@@ -18,8 +18,11 @@ sealed interface NavRequest {
/** One account's detail — where "sign in again" lives. */
data class OpenAccount(val accountId: Long) : NavRequest
- /** A new task, optionally titled with text shared from another app. */
- data class NewTask(val title: String? = null) : NavRequest
+ /**
+ * A new task, optionally titled with text shared from another app, or
+ * preset to a list — a widget's own add button, when it shows one list.
+ */
+ data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
data class OpenSmart(val list: SmartList) : NavRequest
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt
new file mode 100644
index 0000000..5708ae1
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt
@@ -0,0 +1,235 @@
+package de.jeanlucmakiola.agendula.widget
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+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
+import androidx.glance.appwidget.provideContent
+import androidx.glance.color.ColorProvider
+import androidx.glance.currentState
+import androidx.glance.unit.ColorProvider as GlanceColorProvider
+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
+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.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 kotlin.math.min
+
+/**
+ * "Lists" home-screen widget: one shortcut tile per list, in the list's own
+ * colour, showing its open count — navigation to a list rather than a
+ * preview of its tasks, which is what [TaskWidget] already does.
+ */
+class ListsWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ // 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)
+ provideContent {
+ val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
+ val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
+ val listIds = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))
+ val data by remember { context.widgetStats() }.collectAsState(initial = initial)
+ val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
+ FloretGlanceTheme(
+ lightScheme = AgendulaLightFallback,
+ darkScheme = AgendulaDarkFallback,
+ dynamicColor = ready?.snapshot?.dynamicColor ?: true,
+ mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
+ ) {
+ ListsWidgetBody(data, background, listIds)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listIds: Set?) {
+ 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 size = LocalSize.current
+ WidgetSurface(background = background) {
+ Column(modifier = GlanceModifier.fillMaxSize().padding(Padding - Gap / 2)) {
+ 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 grid = listGrid(tiles.size, size.width - Padding * 2, size.height - Padding * 2)
+ val rows = tiles.take(grid.columns * grid.rows).chunked(grid.columns)
+ // Gaps are each tile's padding rather than Spacers, which would
+ // eat into Glance's 10-children-per-Row/Column cap.
+ rows.forEach { rowTiles ->
+ Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
+ rowTiles.forEach { tile ->
+ // Glance has no margin — padding on the tile itself lands inside its fill.
+ Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(Gap / 2)) {
+ ListTileCell(tile, grid, GlanceModifier.fillMaxSize())
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+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 ((height + Gap) / (minHeight + Gap)).toInt().coerceIn(1, MaxCells)
+ }
+ val maxColumns = ((width + Gap) / (MinTileWidth + Gap)).toInt().coerceIn(1, MaxCells)
+ 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 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 ListAddButton(tile: ListTile) {
+ val context = LocalContext.current
+ 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
+
+/** Glance's per-Row/Column child cap. */
+private const val MaxCells = 10
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidgetReceiver.kt
new file mode 100644
index 0000000..d88ac77
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidgetReceiver.kt
@@ -0,0 +1,27 @@
+package de.jeanlucmakiola.agendula.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import de.jeanlucmakiola.floret.glance.WidgetRollover
+
+/** Host-facing receiver for [ListsWidget]; also keeps the midnight rollover armed. */
+class ListsWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = ListsWidget()
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt
index 59df7b0..88a8426 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt
@@ -6,13 +6,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-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.action.ActionParameters
@@ -23,12 +23,10 @@ import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
-import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.getAppWidgetState
-import androidx.glance.background
import androidx.glance.color.ColorProvider
import androidx.glance.currentState
import androidx.glance.layout.Alignment
@@ -45,20 +43,40 @@ 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
import de.jeanlucmakiola.agendula.R
-import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
+import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
+import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
+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.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.WidgetDoneState
+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
/**
- * "Tasks" home-screen widget: one smart list (Today by default) or one real
- * list per instance, with a checkbox to complete each task in place.
+ * "Tasks" home-screen widget: one smart list (Today by default) or one or more
+ * real lists per instance, with a Calendar-agenda look — a plain section label
+ * per group (by list, or by due bucket: Overdue / Today / Tomorrow / Later /
+ * No date) above a run of standalone, fully-rounded task pills, each with a
+ * trailing check to complete it in place.
*
* The data is collected live inside the composition rather than loaded once in
* the `provideGlance` preamble: an `update` on a running session only
@@ -69,7 +87,7 @@ class TaskWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
- override val sizeMode = SizeMode.Single
+ override val sizeMode = SizeMode.Responsive(setOf(CompactSize, RegularSize))
override suspend fun provideGlance(context: Context, id: GlanceId) {
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
@@ -80,60 +98,73 @@ class TaskWidget : GlanceAppWidget() {
provideContent {
val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY))
val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false
+ val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
+ val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
+ val rowColor = parseWidgetRowColor(currentState(WIDGET_ROW_COLOR_KEY))
val data by remember(filter, groupByList) { context.taskWidgetData(filter, groupByList) }
.collectAsState(initial = if (filter == initialFilter && groupByList == initialGroupByList) initial else null)
val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
- AgendulaGlanceTheme(
+ FloretGlanceTheme(
+ lightScheme = AgendulaLightFallback,
+ darkScheme = AgendulaDarkFallback,
dynamicColor = ready?.dynamicColor ?: true,
- themeMode = ready?.themeMode ?: ThemeMode.SYSTEM,
+ mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM,
) {
- TaskWidgetBody(filter, data)
+ TaskWidgetBody(filter, data, background, rowColor)
}
}
}
+
+ private companion object {
+ val CompactSize = DpSize(110.dp, 110.dp)
+ val RegularSize = DpSize(250.dp, 110.dp)
+ }
}
/** Completes the tapped task — just the one occurrence for a recurring series. */
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) }
+ 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.
+ // First the ticked frame, then the real completion a beat later.
+ updateAllWidgets(app)
+ delay(TICK_FRAME_MILLIS)
runCatching {
- context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true)
+ context.widgetEntryPoint().tasksRepository()
+ .setCompleted(taskId, occurrenceMillis?.let { Instant.fromEpochMilliseconds(it) }, true)
}
- TaskWidget().update(context.applicationContext, glanceId)
+ updateAllWidgets(app)
+ // Hold the ticked look until the store's change has redrawn the row away.
+ delay(TICK_RELEASE_MILLIS)
+ WidgetCompletions.finish(key)
+ updateAllWidgets(app)
}
companion object {
val TaskIdKey = ActionParameters.Key("taskId")
val OccurrenceKey = ActionParameters.Key("occurrenceStart")
const val NO_OCCURRENCE = Long.MIN_VALUE
+ private const val TICK_FRAME_MILLIS = 600L
+ private const val TICK_RELEASE_MILLIS = 1500L
}
}
-/** A row in the widget's list: a task, or (grouped widgets) a list-name header. */
-private sealed interface WidgetListItem {
- data class Header(val listId: Long, val name: String) : WidgetListItem
- data class TaskItem(val row: WidgetTaskRow) : WidgetListItem
-}
-
-/** Inserts a [WidgetListItem.Header] before each run of same-list rows. */
-private fun groupedItems(rows: List, listNames: Map): List {
- val result = mutableListOf()
- var lastListId: Long? = null
- for (row in rows) {
- if (row.listId != lastListId) {
- result += WidgetListItem.Header(row.listId, listNames[row.listId].orEmpty())
- lastListId = row.listId
- }
- result += WidgetListItem.TaskItem(row)
- }
- return result
-}
-
@Composable
-private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
+private fun TaskWidgetBody(
+ filter: TaskFilter,
+ data: TaskWidgetData?,
+ background: WidgetBackground,
+ rowColor: WidgetRowColor,
+) {
val context = LocalContext.current
val ready = data as? TaskWidgetData.Ready
val title = when (filter) {
@@ -141,41 +172,91 @@ private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
?: context.getString(R.string.widget_tasks_label)
}
- Column(
- modifier = GlanceModifier
- .fillMaxSize()
- .background(GlanceTheme.colors.widgetBackground)
- .padding(horizontal = 8.dp, vertical = 6.dp),
- ) {
- Header(title, filter)
- Spacer(GlanceModifier.height(4.dp))
- when (data) {
- null -> Unit
- TaskWidgetData.Unavailable -> Message(context.getString(R.string.widget_unavailable))
- is TaskWidgetData.Ready ->
- if (data.rows.isEmpty()) {
- Message(context.getString(R.string.widget_empty))
- } else {
- val items = if (data.groupByList) {
- groupedItems(data.rows, data.listNames)
+ WidgetSurface(background = background) {
+ Column(
+ modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
+ ) {
+ TaskWidgetHeader(title, filter, ready?.singleListId)
+ Spacer(GlanceModifier.height(2.dp))
+ when (data) {
+ null -> Unit
+ TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
+ is TaskWidgetData.Ready ->
+ if (data.rows.isEmpty()) {
+ WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
} else {
- data.rows.map { WidgetListItem.TaskItem(it) }
- }
- LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
- items(items.size) { index ->
- when (val item = items[index]) {
- is WidgetListItem.Header -> ListHeader(item.name)
- is WidgetListItem.TaskItem -> TaskRow(item.row, data.use24Hour)
+ val allSections = widgetSections(
+ data.rows,
+ data.groupByList,
+ data.listNames,
+ ) { kind -> context.getString(dueBucketLabel(kind)) }
+ // A lone group whose bucket repeats the widget's own smart list
+ // (the "Today" smart list showing one "Today" bucket) earns no
+ // place of its own. A lone group for any other bucket (a
+ // real-list widget whose only bucket is "Tomorrow") still has to
+ // say so — it's the one thing telling the rows apart from today.
+ // Compared by due kind, not by rendered label text, since the two
+ // labels come from separately translated string resources and
+ // aren't guaranteed to read identically in every locale.
+ val onlyHeader = allSections.singleOrNull { it is WidgetSection.Header } as? WidgetSection.Header
+ val redundantDueKind = when ((filter as? TaskFilter.Smart)?.list) {
+ SmartList.TODAY -> DueKind.TODAY
+ SmartList.OVERDUE -> DueKind.PAST
+ else -> null
+ }
+ val redundantHeader = onlyHeader != null && redundantDueKind != null &&
+ onlyHeader.key == "due:${redundantDueKind.name}"
+ val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
+ LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
+ items(sections.size) { index ->
+ when (val section = sections[index]) {
+ is WidgetSection.Header -> SectionHeader(section.name)
+ is WidgetSection.TaskItem -> {
+ val container = if (rowColor == WidgetRowColor.BY_LIST) {
+ ColorProvider(
+ day = pastelize(section.row.color, false),
+ night = pastelize(section.row.color, true),
+ )
+ } else {
+ GlanceTheme.colors.surfaceVariant
+ }
+ // A LazyColumn item's host view isn't a linear layout on
+ // its own, so the row and its gap need an explicit Column
+ // to stack rather than overlap.
+ Column {
+ AgendaTaskRow(
+ row = section.row,
+ use24Hour = data.use24Hour,
+ color = container,
+ colored = rowColor == WidgetRowColor.BY_LIST,
+ // The due-bucket header above already names the day
+ // (Today / Tomorrow) whenever one is shown — and when
+ // it isn't, that's only because it would have repeated
+ // the title, which names the same day just as well.
+ suppressDayLabel = !data.groupByList,
+ )
+ Spacer(GlanceModifier.height(8.dp))
+ }
+ }
+ }
}
}
}
- }
+ }
}
}
}
+private fun dueBucketLabel(kind: DueKind?): Int = when (kind) {
+ DueKind.PAST -> R.string.widget_section_overdue
+ DueKind.TODAY -> R.string.home_due_today
+ DueKind.TOMORROW -> R.string.home_due_tomorrow
+ DueKind.LATER -> R.string.widget_section_later
+ null -> R.string.widget_section_no_date
+}
+
@Composable
-private fun ListHeader(name: String) {
+private fun SectionHeader(name: String) {
Text(
text = name,
maxLines = 1,
@@ -189,7 +270,7 @@ private fun ListHeader(name: String) {
}
@Composable
-private fun Header(title: String, filter: TaskFilter) {
+private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Long?) {
val context = LocalContext.current
val open = when (filter) {
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
@@ -199,121 +280,102 @@ private fun Header(title: String, filter: TaskFilter) {
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
- Row(
- modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
+ WidgetHeader(
+ title = title,
+ titleAction = actionStartActivity(open),
) {
- Text(
- text = title,
- maxLines = 1,
- style = TextStyle(
- color = GlanceTheme.colors.primary,
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- ),
- modifier = GlanceModifier.defaultWeight().clickable(actionStartActivity(open)),
+ WidgetHeaderAction(
+ icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
+ contentDescription = context.getString(R.string.new_task),
+ action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
)
- Box(
- modifier = GlanceModifier
- .size(40.dp)
- .cornerRadius(20.dp)
- .background(GlanceTheme.colors.primaryContainer)
- .clickable(actionStartActivity(MainActivity.newTaskIntent(context))),
- contentAlignment = Alignment.Center,
- ) {
- Image(
- provider = ImageProvider(R.drawable.ic_widget_add),
- contentDescription = context.getString(R.string.new_task),
- colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer),
- modifier = GlanceModifier.size(22.dp),
- )
- }
}
}
+/**
+ * A Calendar-agenda-style task chip: its own fully-rounded pill (list colour
+ * or a neutral tone, per [WidgetRowColor]), a leading check to complete it,
+ * title and due to its right. [colored] pills use fixed light text/icon —
+ * [pastelize][de.jeanlucmakiola.floret.components.pastelize] keeps them
+ * mid-bright rather than pale, so the theme's own `onSurface` (tuned for the
+ * neutral tonal fill) reads too dark against them.
+ */
@Composable
-private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
+internal fun AgendaTaskRow(
+ row: WidgetTaskRow,
+ use24Hour: Boolean,
+ color: GlanceColorProvider,
+ colored: Boolean,
+ suppressDayLabel: Boolean,
+) {
val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val open = actionStartActivity(
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
)
- Row(
- modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- modifier = GlanceModifier
- .width(4.dp)
- .height(32.dp)
- .cornerRadius(2.dp)
- .background(ColorProvider(day = pastelize(row.color, false), night = pastelize(row.color, true))),
- ) {}
- Box(
- modifier = GlanceModifier
- .size(40.dp)
- .clickable(
- actionRunCallback(
- actionParametersOf(
- CompleteTaskAction.TaskIdKey to row.taskId,
- CompleteTaskAction.OccurrenceKey to
- (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
- ),
+ 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),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ WidgetCheckButton(
+ contentDescription = context.getString(R.string.widget_complete_task, title),
+ action = actionRunCallback(
+ actionParametersOf(
+ CompleteTaskAction.TaskIdKey to row.taskId,
+ CompleteTaskAction.OccurrenceKey to
+ (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
),
),
- contentAlignment = Alignment.Center,
- ) {
- Image(
- provider = ImageProvider(R.drawable.ic_widget_checkbox),
- contentDescription = context.getString(R.string.widget_complete_task, title),
- colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
- modifier = GlanceModifier.size(22.dp),
+ tint = onColorVariant,
+ checked = completing,
)
- }
- Column(
- modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp)
- .clickable(open),
- ) {
- Text(
- text = title,
- maxLines = 1,
- style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
- )
- dueText(context, row, use24Hour)?.let { due ->
+ Spacer(GlanceModifier.width(8.dp))
+ Column(modifier = GlanceModifier.defaultWeight().clickable(open)) {
Text(
- text = due,
+ text = title,
maxLines = 1,
style = TextStyle(
- color = if (row.isOverdue) GlanceTheme.colors.error
- else GlanceTheme.colors.onSurfaceVariant,
- fontSize = 12.sp,
+ 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(
+ text = due,
+ maxLines = 1,
+ style = TextStyle(
+ color = if (row.isOverdue) onColorError else onColorVariant,
+ fontSize = 12.sp,
+ ),
+ )
+ }
}
}
}
}
-@Composable
-private fun Message(text: String) {
- Box(
- modifier = GlanceModifier.fillMaxSize().padding(16.dp),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = text,
- style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
- )
- }
-}
-
-/** "Today · 14:00", "Tomorrow", or the compact date; `null` without a due date. */
-private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String? {
+/**
+ * The chip's own due line — bare "14:00" when [suppressDayLabel] (the due-bucket
+ * header above already says "Today"/"Tomorrow", Calendar-agenda style: the day
+ * is the group, the chip only needs the time), otherwise "Today · 14:00" /
+ * "Tomorrow", or the compact date for anything else. `null` without a due date,
+ * or for an all-day task once its day is already said by the header.
+ */
+internal fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean, suppressDayLabel: Boolean): String? {
val due = row.due ?: return null
val relative = when (row.dueKind) {
DueKind.TODAY -> context.getString(R.string.home_due_today)
DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow)
else -> null
} ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour)
- return if (row.isAllDay) relative else "$relative · ${due.formatTime(use24Hour)}"
+ if (row.isAllDay) return if (suppressDayLabel) null else relative
+ val time = due.formatTime(use24Hour)
+ return if (suppressDayLabel) time else "$relative · $time"
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt
index 64fc4cb..c68cded 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.widget
import android.appwidget.AppWidgetManager
+import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -28,6 +29,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
@@ -46,19 +48,50 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
+import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
+/** Which widget provider a configure session is for — decides which sections [WidgetFilterPicker] shows. */
+internal enum class WidgetKind { TASKS, TODAY, UP_NEXT, LISTS, WEEK }
+
+/** Reads [appWidgetId]'s provider class from the host, since that's the only thing naming which widget this is. */
+private fun widgetKindOf(context: Context, appWidgetId: Int): WidgetKind? {
+ val provider = AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider ?: return null
+ return when (provider.className) {
+ TaskWidgetReceiver::class.java.name -> WidgetKind.TASKS
+ TodayWidgetReceiver::class.java.name -> WidgetKind.TODAY
+ UpNextWidgetReceiver::class.java.name -> WidgetKind.UP_NEXT
+ ListsWidgetReceiver::class.java.name -> WidgetKind.LISTS
+ WeekWidgetReceiver::class.java.name -> WidgetKind.WEEK
+ else -> null
+ }
+}
+
+private fun widgetFor(kind: WidgetKind): GlanceAppWidget = when (kind) {
+ WidgetKind.TASKS -> TaskWidget()
+ WidgetKind.TODAY -> TodayWidget()
+ WidgetKind.UP_NEXT -> UpNextWidget()
+ WidgetKind.LISTS -> ListsWidget()
+ WidgetKind.WEEK -> WeekWidget()
+}
+
/**
- * Picks what one widget instance shows: a smart list, or one or more real
- * lists (with an optional "group by list" sort). Runs when the widget is
- * placed (below API 31) and on "reconfigure" from the launcher.
+ * Picks what one widget instance shows. [WidgetKind.TASKS] picks a smart
+ * list or one or more real lists (with an optional "group by list" sort);
+ * [WidgetKind.LISTS] picks which lists get a tile; [WidgetKind.UP_NEXT] can be
+ * narrowed to one list; every other kind only
+ * offers background and theme, since they're always the smart-list scope.
+ * Runs when the widget is placed (below API 31) and on "reconfigure" from
+ * the launcher.
*/
@AndroidEntryPoint
class TaskWidgetConfigActivity : ComponentActivity() {
@@ -76,6 +109,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
finish()
return
}
+ val kind = widgetKindOf(this, appWidgetId) ?: WidgetKind.TASKS
enableEdgeToEdge()
val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId)
val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow())
@@ -86,10 +120,18 @@ class TaskWidgetConfigActivity : ComponentActivity() {
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
var current by remember { mutableStateOf(null) }
var groupByList by remember { mutableStateOf(false) }
+ var background by remember { mutableStateOf(WidgetBackground.SOLID) }
+ var themeOverride by remember { mutableStateOf(null) }
+ var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
+ var selectedListIds by remember { mutableStateOf?>(null) }
LaunchedEffect(glanceId) {
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
+ background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY], kind)
+ themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY])
+ rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
+ selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
}
val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -99,18 +141,40 @@ class TaskWidgetConfigActivity : ComponentActivity() {
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
WidgetFilterPicker(
+ kind = kind,
lists = lists.filter { it.isVisible },
dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER,
groupByList = groupByList,
onGroupByListChange = { groupByList = it },
+ background = background,
+ onBackgroundChange = { background = it },
+ themeOverride = themeOverride,
+ onThemeOverrideChange = { themeOverride = it },
+ rowColor = rowColor,
+ onRowColorChange = { rowColor = it },
+ selectedListIds = selectedListIds,
+ onSelectedListIdsChange = { selectedListIds = it },
onSave = { filter ->
lifecycleScope.launch {
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
- it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
- it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
+ when (kind) {
+ WidgetKind.TASKS -> {
+ it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
+ it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
+ it[WIDGET_ROW_COLOR_KEY] = rowColor.name
+ }
+ WidgetKind.LISTS, WidgetKind.UP_NEXT -> {
+ selectedListIds?.let { ids -> it[WIDGET_LIST_IDS_KEY] = ids.widgetListIdsStorageValue() }
+ ?: it.remove(WIDGET_LIST_IDS_KEY)
+ }
+ WidgetKind.TODAY, WidgetKind.WEEK -> Unit
+ }
+ it[WIDGET_BACKGROUND_KEY] = background.name
+ themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name }
+ ?: it.remove(WIDGET_THEME_KEY)
}
- TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
+ widgetFor(kind).update(this@TaskWidgetConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId))
finish()
}
@@ -127,14 +191,26 @@ class TaskWidgetConfigActivity : ComponentActivity() {
@Composable
private fun WidgetFilterPicker(
+ kind: WidgetKind,
lists: List,
selected: TaskFilter,
dark: Boolean,
groupByList: Boolean,
onGroupByListChange: (Boolean) -> Unit,
+ background: WidgetBackground,
+ onBackgroundChange: (WidgetBackground) -> Unit,
+ themeOverride: WidgetThemeMode?,
+ onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
+ rowColor: WidgetRowColor,
+ onRowColorChange: (WidgetRowColor) -> Unit,
+ selectedListIds: Set?,
+ onSelectedListIdsChange: (Set?) -> Unit,
onSave: (TaskFilter) -> Unit,
onDismiss: () -> Unit,
) {
+ var showBackgroundPicker by remember { mutableStateOf(false) }
+ var showThemePicker by remember { mutableStateOf(false) }
+ var showRowColorPicker by remember { mutableStateOf(false) }
// Smart lists and real lists are mutually exclusive; only real lists support
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
// a smart-list pick; the reverse (or both empty, before the stored filter
@@ -149,36 +225,71 @@ private fun WidgetFilterPicker(
},
)
}
- val canSave = selectedSmart != null || selectedLists.isNotEmpty()
+ val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
FullScreenPicker(
- title = stringResource(R.string.widget_config_title),
+ title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
onDismiss = onDismiss,
actions = {
TextButton(
enabled = canSave,
onClick = {
+ // The toggle row only shows (and so can only be turned off) with
+ // more than one list selected; narrowing back down to one, or to
+ // a smart list, has to clear it here instead; otherwise it's
+ // stuck on with no way left to see or undo it.
+ if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
?: onSave(TaskFilter.OfLists(selectedLists))
},
) { Text(stringResource(android.R.string.ok)) }
},
) {
- GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
- WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
- val isSelected = smart == selectedSmart
- GroupedRow(
- title = stringResource(smartListLabel(smart)),
- position = positionOf(index, WIDGET_SMART_LISTS.size),
- selected = isSelected,
- trailing = if (isSelected) ({ SelectedCheck() }) else null,
- onClick = { selectedSmart = smart; selectedLists = emptySet() },
- )
- }
- if (lists.isNotEmpty()) {
+ if (kind == WidgetKind.TASKS) {
+ GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
+ WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
+ val isSelected = smart == selectedSmart
+ GroupedRow(
+ title = stringResource(smartListLabel(smart)),
+ position = positionOf(index, WIDGET_SMART_LISTS.size),
+ selected = isSelected,
+ trailing = if (isSelected) ({ SelectedCheck() }) else null,
+ onClick = { selectedSmart = smart; selectedLists = emptySet() },
+ )
+ }
+ if (lists.isNotEmpty()) {
+ GroupedSectionHeader(stringResource(R.string.lists_header))
+ lists.forEachIndexed { index, list ->
+ val isSelected = list.id in selectedLists
+ GroupedRow(
+ title = list.name,
+ position = positionOf(index, lists.size),
+ selected = isSelected,
+ leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
+ trailing = if (isSelected) ({ SelectedCheck() }) else null,
+ onClick = {
+ selectedSmart = null
+ selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
+ },
+ )
+ }
+ if (selectedLists.size > 1) {
+ Spacer(Modifier.height(24.dp))
+ GroupedRow(
+ title = stringResource(R.string.widget_config_group_by_list),
+ summary = stringResource(R.string.widget_config_group_by_list_hint),
+ position = Position.Alone,
+ trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
+ onClick = { onGroupByListChange(!groupByList) },
+ )
+ }
+ }
+ Spacer(Modifier.height(24.dp))
+ } else if (kind == WidgetKind.LISTS && lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list ->
- val isSelected = list.id in selectedLists
+ // `null` means "every list" (the default) until the user narrows it down.
+ val isSelected = selectedListIds?.let { list.id in it } ?: true
GroupedRow(
title = list.name,
position = positionOf(index, lists.size),
@@ -186,22 +297,96 @@ private fun WidgetFilterPicker(
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = {
- selectedSmart = null
- selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
+ val current = selectedListIds ?: lists.map { it.id }.toSet()
+ onSelectedListIdsChange(if (isSelected) current - list.id else current + list.id)
},
)
}
- if (selectedLists.size > 1) {
- Spacer(Modifier.height(24.dp))
+ 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 = stringResource(R.string.widget_config_group_by_list),
- summary = stringResource(R.string.widget_config_group_by_list_hint),
- position = Position.Alone,
- trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
- onClick = { onGroupByListChange(!groupByList) },
+ title = list.name,
+ position = positionOf(index + 1, lists.size + 1),
+ selected = isSelected,
+ leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
+ trailing = if (isSelected) ({ SelectedCheck() }) else null,
+ onClick = { onSelectedListIdsChange(setOf(list.id)) },
)
}
+ Spacer(Modifier.height(24.dp))
}
+ GroupedRow(
+ title = stringResource(R.string.widget_config_background),
+ summary = stringResource(backgroundLabel(background)),
+ position = Position.Top,
+ onClick = { showBackgroundPicker = true },
+ )
+ if (kind == WidgetKind.TASKS) {
+ GroupedRow(
+ title = stringResource(R.string.widget_config_theme),
+ summary = stringResource(themeOverrideLabel(themeOverride)),
+ position = Position.Middle,
+ onClick = { showThemePicker = true },
+ )
+ GroupedRow(
+ title = stringResource(R.string.widget_config_row_color),
+ summary = stringResource(rowColorLabel(rowColor)),
+ position = Position.Bottom,
+ onClick = { showRowColorPicker = true },
+ )
+ } else {
+ GroupedRow(
+ title = stringResource(R.string.widget_config_theme),
+ summary = stringResource(themeOverrideLabel(themeOverride)),
+ position = Position.Bottom,
+ onClick = { showThemePicker = true },
+ )
+ }
+ }
+
+ if (showBackgroundPicker) {
+ OptionPicker(
+ title = stringResource(R.string.widget_config_background),
+ options = kind.backgrounds(),
+ selected = background,
+ label = { stringResource(backgroundLabel(it)) },
+ summary = { stringResource(backgroundHint(it)) },
+ onSelect = onBackgroundChange,
+ onDismiss = { showBackgroundPicker = false },
+ )
+ }
+ if (showThemePicker) {
+ OptionPicker(
+ title = stringResource(R.string.widget_config_theme),
+ options = listOf(null, WidgetThemeMode.LIGHT, WidgetThemeMode.DARK),
+ selected = themeOverride,
+ label = { stringResource(themeOverrideLabel(it)) },
+ onSelect = onThemeOverrideChange,
+ onDismiss = { showThemePicker = false },
+ )
+ }
+ if (showRowColorPicker) {
+ OptionPicker(
+ title = stringResource(R.string.widget_config_row_color),
+ options = WidgetRowColor.entries,
+ selected = rowColor,
+ label = { stringResource(rowColorLabel(it)) },
+ summary = { stringResource(rowColorHint(it)) },
+ onSelect = onRowColorChange,
+ onDismiss = { showRowColorPicker = false },
+ )
}
}
@@ -211,3 +396,32 @@ internal fun smartListLabel(list: SmartList): Int = when (list) {
SmartList.OVERDUE -> R.string.smart_overdue
else -> R.string.smart_all
}
+
+private fun backgroundLabel(background: WidgetBackground): Int = when (background) {
+ WidgetBackground.SOLID -> R.string.widget_config_background_solid
+ WidgetBackground.TINTED -> R.string.widget_config_background_tinted
+ WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
+}
+
+private fun backgroundHint(background: WidgetBackground): Int = when (background) {
+ WidgetBackground.SOLID -> R.string.widget_config_background_solid_hint
+ WidgetBackground.TINTED -> R.string.widget_config_background_tinted_hint
+ WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent_hint
+}
+
+private fun themeOverrideLabel(mode: WidgetThemeMode?): Int = when (mode) {
+ null -> R.string.widget_config_theme_follow_app
+ WidgetThemeMode.LIGHT -> R.string.widget_config_theme_light
+ WidgetThemeMode.DARK -> R.string.widget_config_theme_dark
+ WidgetThemeMode.SYSTEM -> R.string.widget_config_theme_follow_app
+}
+
+private fun rowColorLabel(rowColor: WidgetRowColor): Int = when (rowColor) {
+ WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral
+ WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list
+}
+
+private fun rowColorHint(rowColor: WidgetRowColor): Int = when (rowColor) {
+ WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral_hint
+ WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list_hint
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt
index e0a0263..112fa3b 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import de.jeanlucmakiola.floret.glance.WidgetRollover
/** Host-facing receiver for [TaskWidget]; also keeps the midnight rollover armed. */
class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
@@ -11,17 +12,17 @@ class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
override fun onEnabled(context: Context) {
super.onEnabled(context)
- WidgetRolloverScheduler.sync(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
- WidgetRolloverScheduler.sync(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
/** Self-heal on the system's `updatePeriodMillis` wake-up, should the alarm have been dropped. */
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
- WidgetRolloverScheduler.sync(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt
index 7a3869f..5802a6c 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt
@@ -1,10 +1,10 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
-import androidx.glance.appwidget.updateAll
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
+import de.jeanlucmakiola.floret.glance.WidgetRollover
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -36,7 +36,9 @@ class TaskWidgetUpdater @Inject constructor(
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
}
- scope.launch(Dispatchers.IO) { runCatching { WidgetRolloverScheduler.sync(context) } }
+ scope.launch(Dispatchers.IO) {
+ runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
+ }
// The process may have been started by a change it never heard about.
requestRefresh()
}
@@ -48,7 +50,8 @@ class TaskWidgetUpdater @Inject constructor(
private suspend fun redraw() {
runCatching {
- if (WidgetRolloverScheduler.hasPlacedWidgets(context)) TaskWidget().updateAll(context)
+ if (!WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) return@runCatching
+ updateAllWidgets(context)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt
new file mode 100644
index 0000000..f5922e1
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt
@@ -0,0 +1,234 @@
+package de.jeanlucmakiola.agendula.widget
+
+import android.content.Context
+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.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+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
+import androidx.glance.text.TextAlign
+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.glance.FloretGlanceTheme
+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
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
+import de.jeanlucmakiola.floret.glance.R as GlanceR
+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 today's open tasks listed
+ * below (or beside, when wide) once the widget has room for them.
+ */
+class TodayWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ // 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)
+ provideContent {
+ val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY), WidgetKind.TODAY)
+ val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
+ val data by remember { context.widgetStats() }.collectAsState(initial = initial)
+ val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
+ FloretGlanceTheme(
+ lightScheme = AgendulaLightFallback,
+ darkScheme = AgendulaDarkFallback,
+ dynamicColor = ready?.snapshot?.dynamicColor ?: true,
+ mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
+ ) {
+ TodayWidgetBody(data, background)
+ }
+ }
+ }
+}
+
+@Composable
+private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
+ val context = LocalContext.current
+ val ready = data as? WidgetStatsData.Ready
+ 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(Padding)) {
+ WidgetHeader(
+ title = context.getString(R.string.smart_today),
+ titleAction = actionStartActivity(MainActivity.todayIntent(context)),
+ ) {
+ WidgetHeaderAction(
+ icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
+ contentDescription = context.getString(R.string.new_task),
+ action = actionStartActivity(MainActivity.newTaskIntent(context)),
+ )
+ }
+ Spacer(GlanceModifier.height(HeaderGap))
+ when {
+ data == null -> Unit
+ ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
+ else -> {
+ 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)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+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 = progress,
+ size = ringSize,
+ trackColor = GlanceTheme.colors.surfaceVariant,
+ indicatorColor = indicator,
+ ) {
+ 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 * scale).sp,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ } else if (allDone) {
+ Text(
+ text = context.getString(R.string.widget_today_all_done),
+ maxLines = 2,
+ style = TextStyle(
+ color = GlanceTheme.colors.tertiary,
+ fontSize = (13 * scale).sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ } else {
+ Text(
+ text = "${snapshot.todayDone}/$total",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = (18 * scale).sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ }
+ }
+}
+
+private val Padding = 8.dp
+private val HeaderHeight = 36.dp
+private val HeaderGap = 4.dp
+private val ListGap = 8.dp
+private val RowHeight = 48.dp
+private val MinRing = 72.dp
+private val MaxRing = 160.dp
+private val MinSideBySideWidth = 230.dp
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidgetReceiver.kt
new file mode 100644
index 0000000..e4ea468
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidgetReceiver.kt
@@ -0,0 +1,27 @@
+package de.jeanlucmakiola.agendula.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import de.jeanlucmakiola.floret.glance.WidgetRollover
+
+/** Host-facing receiver for [TodayWidget]; also keeps the midnight rollover armed. */
+class TodayWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = TodayWidget()
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt
new file mode 100644
index 0000000..ad3098a
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt
@@ -0,0 +1,410 @@
+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.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+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.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.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. 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.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), WidgetKind.UP_NEXT)
+ 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(
+ lightScheme = AgendulaLightFallback,
+ darkScheme = AgendulaDarkFallback,
+ dynamicColor = ready?.snapshot?.dynamicColor ?: true,
+ mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
+ ) {
+ UpNextWidgetBody(data, background, listId)
+ }
+ }
+ }
+}
+
+@Composable
+private fun UpNextWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listId: Long?) {
+ val context = LocalContext.current
+ val ready = data as? WidgetStatsData.Ready
+ 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))
+ 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(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, 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 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),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ 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 = if (height >= 88.dp) 2 else 1,
+ style = titleStyle(row, 15f),
+ )
+ }
+ }
+}
+
+/**
+ * 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.
+ */
+@Composable
+private fun UpNextTall(rows: List, snapshot: WidgetStatsSnapshot, list: ListTile?, height: Dp) {
+ val context = LocalContext.current
+ val row = rows.first()
+ val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
+ val now = Clock.System.now()
+ val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary
+ val heroSize = (height.value * 0.15f).coerceIn(26f, 38f)
+ val checkSize = (height * 0.2f).coerceIn(44.dp, 56.dp)
+ val laterCount = ((height - MinTallHeight - 40.dp) / LaterRowHeight).toInt().coerceIn(0, minOf(rows.size - 1, MAX_LATER_ROWS))
+ 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))
+ Column { rows.drop(1).take(laterCount).forEach { later -> LaterRow(later, snapshot.use24Hour) } }
+ }
+ }
+}
+
+/** The cookie check; while [WidgetCompletions] holds the row it bursts into a filled sunny shape. */
+@Composable
+private fun CompleteButton(row: WidgetTaskRow, title: String, size: Dp) {
+ val context = LocalContext.current
+ val completing = WidgetCompletions.isCompleting(row)
+ WidgetShapedIconButton(
+ shape = if (completing) WidgetShape.SUNNY else WidgetShape.COOKIE,
+ icon = ImageProvider(GlanceR.drawable.floret_ic_widget_check),
+ contentDescription = context.getString(R.string.widget_complete_task, title),
+ action = completeTask(row),
+ color = when {
+ completing -> GlanceTheme.colors.tertiary
+ row.isOverdue -> GlanceTheme.colors.errorContainer
+ else -> GlanceTheme.colors.primaryContainer
+ },
+ iconTint = when {
+ completing -> GlanceTheme.colors.onTertiary
+ row.isOverdue -> GlanceTheme.colors.onErrorContainer
+ else -> GlanceTheme.colors.onPrimaryContainer
+ },
+ size = size,
+ )
+}
+
+@Composable
+private fun RelativePill(text: String, overdue: Boolean, done: Boolean = false) {
+ val container = when {
+ done -> GlanceTheme.colors.tertiaryContainer
+ overdue -> GlanceTheme.colors.errorContainer
+ else -> GlanceTheme.colors.secondaryContainer
+ }
+ val content = when {
+ done -> GlanceTheme.colors.onTertiaryContainer
+ overdue -> GlanceTheme.colors.onErrorContainer
+ else -> GlanceTheme.colors.onSecondaryContainer
+ }
+ Box(
+ modifier = GlanceModifier.cornerRadius(10.dp)
+ .background(container)
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ ) {
+ Text(
+ text = text,
+ maxLines = 1,
+ style = TextStyle(color = content, fontSize = 11.sp, fontWeight = FontWeight.Bold),
+ )
+ }
+}
+
+/** The relative pill's text, or "Done" while the row is being ticked off. */
+@Composable
+private fun RowPill(row: WidgetTaskRow, relative: String) {
+ val context = LocalContext.current
+ if (WidgetCompletions.isCompleting(row)) {
+ RelativePill(context.getString(R.string.reminder_action_done), row.isOverdue, done = true)
+ } else if (relative.isNotEmpty()) {
+ RelativePill(relative, row.isOverdue)
+ }
+}
+
+@Composable
+private fun titleStyle(row: WidgetTaskRow, fontSize: Float): TextStyle {
+ val completing = WidgetCompletions.isCompleting(row)
+ return TextStyle(
+ color = if (completing) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface,
+ fontSize = fontSize.sp,
+ fontWeight = FontWeight.Medium,
+ textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None,
+ )
+}
+
+@Composable
+private fun LaterRow(row: WidgetTaskRow, use24Hour: Boolean) {
+ val context = LocalContext.current
+ Row(
+ modifier = GlanceModifier.fillMaxWidth().height(LaterRowHeight).clickable(openTask(context, row)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ListDot(row.color)
+ Spacer(GlanceModifier.width(8.dp))
+ Text(
+ text = row.title.ifBlank { context.getString(R.string.task_untitled) },
+ maxLines = 1,
+ modifier = GlanceModifier.defaultWeight(),
+ style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp),
+ )
+ Text(
+ text = upNextHero(context, row, use24Hour),
+ maxLines = 1,
+ style = TextStyle(
+ color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp,
+ ),
+ )
+ }
+}
+
+@Composable
+private fun ListDot(color: Int) {
+ Box(
+ modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp)
+ .background(ColorProvider(day = pastelize(color, false), night = pastelize(color, true))),
+ ) {}
+}
+
+private fun openTask(context: Context, row: WidgetTaskRow): Action =
+ actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()))
+
+private fun completeTask(row: WidgetTaskRow): Action =
+ actionRunCallback(
+ actionParametersOf(
+ CompleteTaskAction.TaskIdKey to row.taskId,
+ CompleteTaskAction.OccurrenceKey to (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
+ ),
+ )
+
+/**
+ * The big line: a timed task's clock time (with its weekday once it's past
+ * tomorrow), or an all-day task's day.
+ */
+internal fun upNextHero(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String {
+ val due = row.due ?: return ""
+ 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 {
+ hours < 1 -> context.getString(R.string.widget_up_next_soon)
+ hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_overdue_hours, hours.toInt(), hours.toInt())
+ else -> {
+ val days = hours / HOURS_PER_DAY
+ context.resources.getQuantityString(R.plurals.widget_up_next_overdue_days, days.toInt(), days.toInt())
+ }
+ }
+ }
+ if (row.dueKind == DueKind.TOMORROW) return context.getString(R.string.widget_up_next_tomorrow)
+ val hours = (due - now).inWholeHours
+ 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 -> 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 const val MAX_LATER_ROWS = 10
+private val MinTallHeight = 150.dp
+private val MinRelativeInlineWidth = 200.dp
+private val LaterRowHeight = 24.dp
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidgetReceiver.kt
new file mode 100644
index 0000000..7a7fca5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidgetReceiver.kt
@@ -0,0 +1,27 @@
+package de.jeanlucmakiola.agendula.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import de.jeanlucmakiola.floret.glance.WidgetRollover
+
+/** Host-facing receiver for [UpNextWidget]; also keeps the midnight rollover armed. */
+class UpNextWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = UpNextWidget()
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt
new file mode 100644
index 0000000..c1c3c7e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt
@@ -0,0 +1,177 @@
+package de.jeanlucmakiola.agendula.widget
+
+import android.content.Context
+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.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
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+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.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.TextAlign
+import androidx.glance.text.TextStyle
+import de.jeanlucmakiola.agendula.MainActivity
+import de.jeanlucmakiola.agendula.R
+import de.jeanlucmakiola.agendula.domain.SmartList
+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.WidgetBackground
+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
+import java.util.Locale
+
+/**
+ * "Week" home-screen widget: a seven-day strip, one tile per day, each with
+ * its open-task count — the one shape none of the app's other widgets or
+ * screens offer (they're all "one list" or "one task" shaped).
+ */
+class WeekWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ // Exact, so the day count can follow the real width.
+ 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 data by remember { context.widgetStats() }.collectAsState(initial = initial)
+ val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
+ FloretGlanceTheme(
+ lightScheme = AgendulaLightFallback,
+ darkScheme = AgendulaDarkFallback,
+ dynamicColor = ready?.snapshot?.dynamicColor ?: true,
+ mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
+ ) {
+ WeekWidgetBody(data, background)
+ }
+ }
+ }
+}
+
+@Composable
+private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
+ val context = LocalContext.current
+ val ready = data as? WidgetStatsData.Ready
+ WidgetSurface(background = background) {
+ when {
+ data == null -> Unit
+ ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
+ else -> {
+ // As many days as fit at a comfortable width, up to the week.
+ // Gaps come from each cell's padding, not Spacers: Glance caps a
+ // Row at 10 children.
+ val days = ((LocalSize.current.width - 8.dp) / MinDayWidth).toInt().coerceIn(1, ready.snapshot.week.size)
+ Row(modifier = GlanceModifier.fillMaxSize().padding(4.dp)) {
+ ready.snapshot.week.take(days).forEach { day ->
+ // Glance has no margin — padding on the tile itself lands inside its fill.
+ Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(horizontal = 2.dp)) {
+ WeekDayCell(day, GlanceModifier.fillMaxSize())
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+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
+ val onBackground = if (today) GlanceTheme.colors.onPrimaryContainer else GlanceTheme.colors.onSurfaceVariant
+ val open = if (today) {
+ actionStartActivity(MainActivity.todayIntent(context))
+ } else {
+ actionStartActivity(MainActivity.smartListIntent(context, SmartList.UPCOMING))
+ }
+ val badgeBackground = if (today) GlanceTheme.colors.primary else GlanceTheme.colors.surface
+ val onBadgeBackground = if (today) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurfaceVariant
+ WidgetTile(color = background, modifier = modifier, onClick = open) {
+ Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
+ val locale = context.resources.configuration.locales[0] ?: Locale.ROOT
+ Spacer(GlanceModifier.defaultWeight())
+ Box(
+ modifier = GlanceModifier
+ .size(38.dp)
+ .cornerRadius(19.dp)
+ .background(badgeBackground),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = day.date.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale),
+ maxLines = 1,
+ style = TextStyle(color = onBadgeBackground, fontSize = 10.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
+ )
+ Text(
+ text = "${day.date.dayOfMonth}",
+ style = TextStyle(color = onBadgeBackground, fontSize = 13.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
+ )
+ }
+ }
+ 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.
+ // 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())
+ }
+ }
+}
+
+private fun isToday(date: LocalDate): Boolean = date == java.time.LocalDate.now()
+
+private val MinDayWidth = 64.dp
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidgetReceiver.kt
new file mode 100644
index 0000000..4445458
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidgetReceiver.kt
@@ -0,0 +1,27 @@
+package de.jeanlucmakiola.agendula.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import de.jeanlucmakiola.floret.glance.WidgetRollover
+
+/** Host-facing receiver for [WeekWidget]; also keeps the midnight rollover armed. */
+class WeekWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = WeekWidget()
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetCompletions.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetCompletions.kt
new file mode 100644
index 0000000..2b93695
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetCompletions.kt
@@ -0,0 +1,31 @@
+package de.jeanlucmakiola.agendula.widget
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * Rows ticked in a widget but not yet completed in the store. Widgets can't
+ * animate, so [CompleteTaskAction] shows the row as done for a beat before
+ * completing it — every widget drawing that row reads this to render the
+ * ticked frame.
+ */
+internal object WidgetCompletions {
+ private val keys = MutableStateFlow>(emptySet())
+
+ fun key(taskId: Long, occurrenceStart: Long?): String = "$taskId@${occurrenceStart ?: ""}"
+
+ /** Marks [key] as completing; `false` if it already was (a double tap). */
+ fun start(key: String): Boolean {
+ var added = false
+ keys.update { current -> if (key in current) current else current + key.also { added = true } }
+ return added
+ }
+
+ fun finish(key: String) = keys.update { it - key }
+
+ @Composable
+ fun isCompleting(row: WidgetTaskRow): Boolean =
+ key(row.taskId, row.occurrenceStart?.toEpochMilliseconds()) in keys.collectAsState().value
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt
index becd60b..4ee3794 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt
@@ -11,6 +11,8 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.domain.comparator
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -25,16 +27,74 @@ internal val WIDGET_FILTER_KEY = stringPreferencesKey("task_filter")
/** Per-instance Glance state key: group a multi-list widget's rows under list headers. */
internal val WIDGET_GROUP_BY_LIST_KEY = booleanPreferencesKey("group_by_list")
+/** Per-instance Glance state key holding the widget's [WidgetBackground] ([name]); unset = [WidgetBackground.SOLID]. */
+internal val WIDGET_BACKGROUND_KEY = stringPreferencesKey("widget_background")
+
+/** Per-instance Glance state key holding a [WidgetThemeMode] override; unset = follow the app's own theme setting. */
+internal val WIDGET_THEME_KEY = stringPreferencesKey("widget_theme")
+
+/** Per-instance Glance state key holding the widget's [WidgetRowColor] ([name]); unset = [WidgetRowColor.NEUTRAL]. */
+internal val WIDGET_ROW_COLOR_KEY = stringPreferencesKey("widget_row_color")
+
+/** Per-instance Glance state key holding the Lists widget's chosen list ids, comma-joined; unset = every visible list. */
+internal val WIDGET_LIST_IDS_KEY = stringPreferencesKey("widget_list_ids")
+
+/** `null` means "every visible list" — the Lists widget's default. */
+internal fun parseWidgetListIds(raw: String?): Set? =
+ raw?.split(",")?.mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }?.toSet()?.takeIf { it.isNotEmpty() }
+
+internal fun Set.widgetListIdsStorageValue(): String = joinToString(",")
+
internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY)
+/** How a row's tonal container is tinted: the neutral surface role, or the task's own list colour. */
+internal enum class WidgetRowColor { NEUTRAL, BY_LIST }
+
+internal fun parseWidgetRowColor(raw: String?): WidgetRowColor =
+ raw?.let { runCatching { WidgetRowColor.valueOf(it) }.getOrNull() } ?: WidgetRowColor.NEUTRAL
+
+internal fun parseWidgetBackground(raw: String?): WidgetBackground =
+ raw?.let { runCatching { WidgetBackground.valueOf(it) }.getOrNull() } ?: WidgetBackground.SOLID
+
+/**
+ * [WidgetBackground]s a widget offers. Today and Up next draw their hero (ring
+ * label, time and title) straight on the surface rather than in containers, so
+ * on [WidgetBackground.TRANSPARENT] it would sit unreadable on the wallpaper.
+ */
+internal fun WidgetKind.backgrounds(): List = when (this) {
+ WidgetKind.TODAY, WidgetKind.UP_NEXT -> WidgetBackground.entries - WidgetBackground.TRANSPARENT
+ else -> WidgetBackground.entries
+}
+
+/** [raw] parsed, falling back to SOLID for a stored value [kind] no longer offers. */
+internal fun parseWidgetBackground(raw: String?, kind: WidgetKind): WidgetBackground =
+ parseWidgetBackground(raw).takeIf { it in kind.backgrounds() } ?: WidgetBackground.SOLID
+
+/** `null` means "follow the app's own theme setting" — resolved by the caller against [TaskWidgetData.Ready.themeMode]. */
+internal fun parseWidgetThemeOverride(raw: String?): WidgetThemeMode? =
+ raw?.let { runCatching { WidgetThemeMode.valueOf(it) }.getOrNull() }
+
+internal fun TaskWidgetData.Ready.effectiveThemeMode(override: WidgetThemeMode?): WidgetThemeMode =
+ effectiveThemeMode(themeMode, override)
+
+/** [override], or [appThemeMode] translated to its Glance counterpart — shared by every widget's data type. */
+internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMode?): WidgetThemeMode =
+ override ?: when (appThemeMode) {
+ ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
+ ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
+ ThemeMode.DARK -> WidgetThemeMode.DARK
+ }
+
/** The smart lists a widget can show, in picker order. */
internal val WIDGET_SMART_LISTS = listOf(SmartList.TODAY, SmartList.UPCOMING, SmartList.OVERDUE, SmartList.ALL)
/**
* Upper bound on rows, so the serialized RemoteViews stays well inside the
- * binder transaction limit however long the list is.
+ * binder transaction limit however long the list is. Lower than the old flat
+ * list's 60: each row is now wrapped in its own tonal container, so the
+ * per-row view count is higher for the same visual density (calendula#214).
*/
-internal const val MAX_WIDGET_ROWS = 60
+internal const val MAX_WIDGET_ROWS = 40
internal fun TaskFilter.widgetStorageValue(): String = when (this) {
is TaskFilter.OfList -> "list:$listId"
@@ -102,15 +162,66 @@ sealed interface TaskWidgetData {
val themeMode: ThemeMode,
val use24Hour: Boolean,
) : TaskWidgetData {
- /** The one list's name, when [filter] names exactly one real list. */
- val singleListName: String? = when (filter) {
- is TaskFilter.OfList -> listNames[filter.listId]
- is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let(listNames::get)
+ /** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */
+ val singleListId: Long? = when (filter) {
+ is TaskFilter.OfList -> filter.listId
+ is TaskFilter.OfLists -> filter.listIds.singleOrNull()
is TaskFilter.Smart -> null
}
+
+ /** [singleListId]'s name. */
+ val singleListName: String? = singleListId?.let(listNames::get)
+
+ val overdueCount: Int = rows.count { it.isOverdue }
}
}
+/** A run of rows in the widget's list: one task, or a run's header. */
+internal sealed interface WidgetSection {
+ data class Header(val key: String, val name: String) : WidgetSection
+ data class TaskItem(val row: WidgetTaskRow) : WidgetSection
+}
+
+/** One headed run: its header's stable [key] and display [name], and the rows under it. */
+private data class WidgetRun(val key: String, val name: String, val rows: List)
+
+/** Each [WidgetRun] as its own [WidgetSection.Header] followed by its rows. */
+private fun headedRuns(runs: List): List =
+ runs.flatMap { run -> listOf(WidgetSection.Header(run.key, run.name)) + run.rows.map(WidgetSection::TaskItem) }
+
+/**
+ * [rows] split into headed runs: one per list when [groupByList] is on
+ * (already sorted that way by [widgetRows]), otherwise one per [DueKind]
+ * bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that
+ * fixed order, since [rows] isn't due-sorted. Each run becomes one header
+ * followed by its own standalone, independently coloured task pills.
+ */
+internal fun widgetSections(
+ rows: List,
+ groupByList: Boolean,
+ listNames: Map,
+ dueBucketLabel: (DueKind?) -> String,
+): List {
+ if (groupByList) {
+ val runs = mutableListOf()
+ var current: MutableList? = null
+ for (row in rows) {
+ if (current == null || runs.last().key != "list:${row.listId}") {
+ current = mutableListOf()
+ runs += WidgetRun("list:${row.listId}", listNames[row.listId].orEmpty(), current)
+ }
+ current.add(row)
+ }
+ return headedRuns(runs)
+ }
+ val buckets = rows.groupBy { it.dueKind }
+ val runs = DueKind.entries.plus(null).mapNotNull { kind ->
+ buckets[kind]?.takeIf { it.isNotEmpty() }
+ ?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) }
+ }
+ return headedRuns(runs)
+}
+
/**
* Open rows for [tasks], sorted the way the app sorts them, then grouped by list
* when [groupByList] is on (stable, so each list's own order survives). A
@@ -130,24 +241,27 @@ internal fun widgetRows(
val sorted = open
.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
.sortedWith(comparator)
- .let { if (groupByList) it.sortedBy { task -> listNames[task.listId].orEmpty() } else it }
- return sorted
- .take(MAX_WIDGET_ROWS)
- .map { task ->
- WidgetTaskRow(
- taskId = task.taskId,
- listId = task.listId,
- occurrenceStart = task.occurrenceStart,
- title = task.title,
- due = task.due,
- isAllDay = task.isAllDay,
- dueKind = task.due?.let { dueKind(it, task.isAllDay, today, zone) },
- isOverdue = task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } ?: false,
- color = task.effectiveColor,
- )
+ .let {
+ if (groupByList) {
+ it.sortedWith(compareBy({ task -> listNames[task.listId].orEmpty() }, { task -> task.listId }))
+ } else it
}
+ return sorted.take(MAX_WIDGET_ROWS).map { it.toWidgetRow(now, today, zone) }
}
+/** This task as a [WidgetTaskRow], resolving its due bucket and overdue state against [today]. */
+internal fun Task.toWidgetRow(now: Instant, today: LocalDate, zone: ZoneId): WidgetTaskRow = WidgetTaskRow(
+ taskId = taskId,
+ listId = listId,
+ occurrenceStart = occurrenceStart,
+ title = title,
+ due = due,
+ isAllDay = isAllDay,
+ dueKind = due?.let { dueKind(it, isAllDay, today, zone) },
+ isOverdue = due?.let { isOverdue(it, isAllDay, now, today, zone) } ?: false,
+ color = effectiveColor,
+)
+
/**
* Live widget data for [filter]. Re-emits on every store change, since the
* repository's flows observe the store; a store that can't be read (permission
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverScheduler.kt
deleted file mode 100644
index 2c646c4..0000000
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverScheduler.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package de.jeanlucmakiola.agendula.widget
-
-import android.app.AlarmManager
-import android.app.PendingIntent
-import android.appwidget.AppWidgetManager
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import androidx.core.content.getSystemService
-import kotlinx.datetime.DateTimeUnit
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.atStartOfDayIn
-import kotlinx.datetime.plus
-import kotlinx.datetime.toLocalDateTime
-import kotlin.time.Clock
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Duration.Companion.seconds
-import kotlin.time.Instant
-
-/**
- * The app's own wake-up for the next local midnight, so "Today" on the widget
- * flips over on the day boundary. `ACTION_DATE_CHANGED` isn't delivered to
- * manifest receivers since Android 8, which is why this exists.
- *
- * One inexact alarm at a time, re-armed by every firing: `setAndAllowWhileIdle`
- * needs no permission and survives doze, and a rollover a few minutes late is
- * invisible on a sleeping screen.
- */
-object WidgetRolloverScheduler {
-
- /** Fire just after midnight, so an early delivery doesn't still read the old date. */
- internal val ROLLOVER_SLACK = 5.seconds
-
- /** Arm the next rollover, or cancel it when no widget is placed. Idempotent. */
- fun sync(context: Context) {
- val appContext = context.applicationContext
- val alarmManager = appContext.getSystemService() ?: return
- val pendingIntent = rolloverPendingIntent(appContext)
- if (!hasPlacedWidgets(appContext)) {
- alarmManager.cancel(pendingIntent)
- return
- }
- val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
- alarmManager.setAndAllowWhileIdle(
- AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
- )
- }
-
- /**
- * The instant just after the next local midnight following [now] in [zone].
- * Uses the actual start of day, so it holds where DST skips midnight or a
- * date-line move skips a whole date.
- */
- fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
- val date = now.toLocalDateTime(zone).date
- var days = 1
- while (days <= MAX_LOOKAHEAD_DAYS) {
- val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
- if (candidate > now) return candidate
- days++
- }
- // Unreachable for any real zone; an hour rather than the slack so it can't loop.
- return now + 1.hours
- }
-
- fun hasPlacedWidgets(context: Context): Boolean {
- val manager = AppWidgetManager.getInstance(context) ?: return false
- return manager.getAppWidgetIds(ComponentName(context, TaskWidgetReceiver::class.java)).isNotEmpty()
- }
-
- private fun rolloverPendingIntent(context: Context): PendingIntent =
- PendingIntent.getBroadcast(
- context,
- ROLLOVER_REQUEST_CODE,
- Intent(context, WidgetUpdateReceiver::class.java)
- .setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
-
- private const val ROLLOVER_REQUEST_CODE = 0x0DA1
-
- private const val MAX_LOOKAHEAD_DAYS = 3
-}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt
new file mode 100644
index 0000000..e048b2a
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt
@@ -0,0 +1,152 @@
+package de.jeanlucmakiola.agendula.widget
+
+import android.content.Context
+import android.text.format.DateFormat
+import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
+import de.jeanlucmakiola.agendula.data.prefs.is24Hour
+import de.jeanlucmakiola.agendula.domain.SmartList
+import de.jeanlucmakiola.agendula.domain.Task
+import de.jeanlucmakiola.agendula.domain.TaskFilter
+import de.jeanlucmakiola.agendula.domain.TaskList
+import de.jeanlucmakiola.agendula.domain.calendarDate
+import de.jeanlucmakiola.agendula.domain.comparator
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
+import java.time.LocalDate
+import java.time.ZoneId
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+/** One day of the Week widget's strip. [first] is that day's earliest task, in sort order. */
+internal data class WeekDay(val date: LocalDate, val count: Int, val overdueCount: Int, val first: WidgetTaskRow?)
+
+/** One tile of the Lists widget. */
+internal data class ListTile(val id: Long, val name: String, val color: Int, val openCount: Int, val acceptsWrites: Boolean)
+
+internal data class WidgetStatsSnapshot(
+ val todayDone: Int,
+ val todayRemaining: Int,
+ val todayTasks: List,
+ /** Open dated tasks, earliest due first — Up next shows the head, optionally narrowed to one list. */
+ val upcoming: List,
+ val week: List,
+ val lists: List,
+ val dynamicColor: Boolean,
+ val themeMode: ThemeMode,
+ val use24Hour: Boolean,
+)
+
+internal sealed interface WidgetStatsData {
+ /** No readable store yet, mirroring [TaskWidgetData.Unavailable]. */
+ data object Unavailable : 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 those tasks as rows.
+ */
+internal fun todayProgress(
+ open: List,
+ completed: List,
+ comparator: Comparator,
+ now: Instant,
+ today: LocalDate,
+ zone: ZoneId,
+): Triple> {
+ val done = completed.count { task ->
+ val at = task.completedAt ?: return@count false
+ at.toLocalDate(zone) == today
+ }
+ val remaining = open.filter { task ->
+ 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.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) })
+}
+
+/** Open dated tasks by earliest due moment, overdue included; undated tasks are skipped. */
+internal fun upcoming(open: List, now: Instant, today: LocalDate, zone: ZoneId): List =
+ open.filter { it.due != null }.sortedBy { it.due!! }.take(MAX_UPCOMING_ROWS).map { it.toWidgetRow(now, today, zone) }
+
+private const val MAX_UPCOMING_ROWS = 200
+
+/**
+ * Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue
+ * task regardless of its own due day, since that's where the app's own
+ * Overdue/Today smart lists put it; every other day matches its own due date.
+ */
+internal fun weekDays(open: List, comparator: Comparator, now: Instant, today: LocalDate, zone: ZoneId): List =
+ (0..6L).map { offset ->
+ val date = today.plusDays(offset)
+ val dayTasks = open.filter { task ->
+ val due = task.due ?: return@filter false
+ if (offset == 0L) {
+ isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date
+ } else {
+ due.calendarDate(task.isAllDay, zone) == date
+ }
+ }
+ val overdueCount = if (offset == 0L) {
+ dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) }
+ } else {
+ 0
+ }
+ val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone)
+ WeekDay(date, dayTasks.size, overdueCount, first)
+ }
+
+/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */
+internal fun listTiles(open: List, lists: List): List {
+ val present = open.mapTo(HashSet()) { it.taskId }
+ val counted = open.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
+ val counts = counted.groupingBy { it.listId }.eachCount()
+ return lists.filter { it.isVisible }.map { list ->
+ ListTile(list.id, list.name, list.color, counts[list.id] ?: 0, list.acceptsWrites)
+ }
+}
+
+private fun Instant.toLocalDate(zone: ZoneId): LocalDate =
+ java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
+
+/**
+ * Live stats for the Today, Up next, Lists and Week widgets — the ALL and
+ * COMPLETED smart lists, filtered the same way the app's own smart lists are
+ * ([de.jeanlucmakiola.agendula.data.prefs.Settings.visibleIn]).
+ */
+internal fun Context.widgetStats(): Flow {
+ val ep = widgetEntryPoint()
+ val repository = ep.tasksRepository()
+ return combine(
+ repository.tasks(TaskFilter.Smart(SmartList.ALL)),
+ repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
+ repository.taskLists(),
+ ep.settingsPrefs().settings,
+ ) { openRaw, completedRaw, lists, settings ->
+ val open = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), openRaw)
+ val completed = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), completedRaw)
+ val now = Clock.System.now()
+ val zone = ZoneId.systemDefault()
+ val today = now.toLocalDate(zone)
+ val comparator = settings.sortOrder.comparator()
+ val (done, remaining, todayTasks) = todayProgress(open, completed, comparator, now, today, zone)
+ val data: WidgetStatsData = WidgetStatsData.Ready(
+ WidgetStatsSnapshot(
+ todayDone = done,
+ todayRemaining = remaining,
+ todayTasks = todayTasks,
+ upcoming = upcoming(open, now, today, zone),
+ week = weekDays(open, comparator, now, today, zone),
+ lists = listTiles(open, lists),
+ dynamicColor = settings.dynamicColor,
+ themeMode = settings.themeMode,
+ use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
+ ),
+ )
+ data
+ }.catch { emit(WidgetStatsData.Unavailable) }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetTheme.kt
deleted file mode 100644
index 5dcc3d7..0000000
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetTheme.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package de.jeanlucmakiola.agendula.widget
-
-import android.os.Build
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.runtime.Composable
-import androidx.glance.GlanceTheme
-import androidx.glance.LocalContext
-import androidx.glance.material3.ColorProviders
-import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
-import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
-import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
-
-private val AgendulaGlanceColors = ColorProviders(
- light = AgendulaLightFallback,
- dark = AgendulaDarkFallback,
-)
-
-/**
- * Glance counterpart of `AgendulaTheme`: Material You on API 31+ when dynamic
- * colour is on, the brand fallback schemes otherwise. On [ThemeMode.SYSTEM] it
- * draws only from colour-role tokens, so light/dark follows the system without a
- * redraw; a forced light or dark theme pins one scheme for both.
- */
-@Composable
-fun AgendulaGlanceTheme(dynamicColor: Boolean, themeMode: ThemeMode, content: @Composable () -> Unit) {
- val dynamic = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
- val context = LocalContext.current
- val colors = when (themeMode) {
- ThemeMode.SYSTEM -> if (dynamic) GlanceTheme.colors else AgendulaGlanceColors
- ThemeMode.LIGHT -> ColorProviders(
- if (dynamic) dynamicLightColorScheme(context) else AgendulaLightFallback,
- )
- ThemeMode.DARK -> ColorProviders(
- if (dynamic) dynamicDarkColorScheme(context) else AgendulaDarkFallback,
- )
- }
- GlanceTheme(colors = colors, content = content)
-}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt
index ceb1cab..c49ff28 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt
@@ -1,56 +1,54 @@
package de.jeanlucmakiola.agendula.widget
-import android.content.BroadcastReceiver
+import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.updateAll
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.launch
+import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
+import de.jeanlucmakiola.floret.glance.WidgetRollover
+
+/** Every home-screen widget's receiver class — the set [WidgetRollover] checks for placed instances. */
+internal val WIDGET_RECEIVER_CLASSES: List> = listOf(
+ TaskWidgetReceiver::class.java,
+ TodayWidgetReceiver::class.java,
+ UpNextWidgetReceiver::class.java,
+ ListsWidgetReceiver::class.java,
+ WeekWidgetReceiver::class.java,
+)
+
+/** Redraws every placed home-screen widget. */
+internal suspend fun updateAllWidgets(context: Context) {
+ TaskWidget().updateAll(context)
+ TodayWidget().updateAll(context)
+ UpNextWidget().updateAll(context)
+ ListsWidget().updateAll(context)
+ WeekWidget().updateAll(context)
+}
+
+internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER"
+
+private const val ROLLOVER_REQUEST_CODE = 0x0DA1
+
+internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
+ context,
+ ROLLOVER_REQUEST_CODE,
+ Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+)
/**
- * Redraws the task widget when its day or its language goes stale, and keeps the
- * midnight rollover armed. Task data changes don't come through here — see
+ * Redraws every widget when its day or language goes stale, and keeps the
+ * midnight rollover armed — the app-specific glue around
+ * [FloretWidgetUpdateReceiver]. Task data changes don't come through here — see
* [TaskWidgetUpdater].
- *
- * Exported for the system broadcasts; an extra redraw from another app is harmless.
*/
-class WidgetUpdateReceiver : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- if (intent.action !in HANDLED_ACTIONS) return
- val appContext = context.applicationContext
- val rearm = intent.action in REARM_ACTIONS
- // The host redraws the widget itself after these two.
- val redraw = intent.action !in REARM_ONLY_ACTIONS
- val pending = goAsync()
- CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
- try {
- if (rearm) WidgetRolloverScheduler.sync(appContext)
- if (redraw && WidgetRolloverScheduler.hasPlacedWidgets(appContext)) {
- TaskWidget().updateAll(appContext)
- }
- } finally {
- pending.finish()
- }
- }
- }
+class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
+ override val rolloverAction = ACTION_WIDGET_ROLLOVER
+ override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
- companion object {
- const val ACTION_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER"
+ override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
- private val REARM_ONLY_ACTIONS = setOf(
- Intent.ACTION_BOOT_COMPLETED,
- Intent.ACTION_MY_PACKAGE_REPLACED,
- )
-
- internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
- ACTION_ROLLOVER,
- Intent.ACTION_TIME_CHANGED,
- Intent.ACTION_TIMEZONE_CHANGED,
- Intent.ACTION_DATE_CHANGED,
- )
-
- internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_LOCALE_CHANGED
+ override suspend fun redraw(context: Context) {
+ updateAllWidgets(context)
}
}
diff --git a/app/src/main/res/drawable/ic_widget_add.xml b/app/src/main/res/drawable/ic_widget_add.xml
deleted file mode 100644
index 4797a8b..0000000
--- a/app/src/main/res/drawable/ic_widget_add.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_widget_checkbox.xml b/app/src/main/res/drawable/ic_widget_checkbox.xml
deleted file mode 100644
index 8ffa575..0000000
--- a/app/src/main/res/drawable/ic_widget_checkbox.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/preview_ring.xml b/app/src/main/res/drawable/preview_ring.xml
new file mode 100644
index 0000000..d1722ed
--- /dev/null
+++ b/app/src/main/res/drawable/preview_ring.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_lists.xml b/app/src/main/res/layout/widget_preview_lists.xml
new file mode 100644
index 0000000..59f8a7f
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_lists.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_tasks.xml b/app/src/main/res/layout/widget_preview_tasks.xml
index d9d14bc..8836ce2 100644
--- a/app/src/main/res/layout/widget_preview_tasks.xml
+++ b/app/src/main/res/layout/widget_preview_tasks.xml
@@ -30,7 +30,7 @@
android:padding="9dp"
android:contentDescription="@null"
android:background="@drawable/preview_add_bg"
- android:src="@drawable/ic_widget_add"
+ android:src="@drawable/floret_ic_widget_add"
android:tint="@color/widget_preview_on_primary_container" />
@@ -52,7 +52,7 @@
android:layout_marginStart="9dp"
android:layout_marginEnd="9dp"
android:contentDescription="@null"
- android:src="@drawable/ic_widget_checkbox"
+ android:src="@drawable/floret_ic_widget_check"
android:tint="@color/widget_preview_variant" />
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_up_next.xml b/app/src/main/res/layout/widget_preview_up_next.xml
new file mode 100644
index 0000000..e299378
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_up_next.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_week.xml b/app/src/main/res/layout/widget_preview_week.xml
new file mode 100644
index 0000000..cb30dd7
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_week.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7c07667..891c4d0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -757,11 +757,68 @@
Tasks
Today\'s tasks, or any list, with a tap to tick them off
- Nothing to do here
+ All caught up
+ Nothing left to do, enjoy it
Open Agendula to finish setting up
Complete %1$s
Show in widget
Smart lists
Group by list
Show each list\'s tasks under its own heading
+ Overdue
+ Later
+ No date
+ Background
+ Solid
+ Fills the widget with the theme\'s surface colour
+ Tinted
+ A softer, tonal fill
+ Transparent
+ No fill — tasks stay readable in their own containers
+ Theme
+ Follow app
+ Light
+ Dark
+ Row colour
+ Neutral
+ Every row uses the same tonal fill
+ By list
+ Each row is tinted with its list\'s own colour
+ Today
+ A progress ring for today\'s tasks
+ All done
+ Nothing due today
+
+ - %1$d of %2$d done
+ - %1$d of %2$d done
+
+ Up next
+ Your single nearest due task
+ Nothing due
+ Soon
+ Tomorrow
+ Then
+ All lists
+
+ - in %1$d h
+ - in %1$d h
+
+
+ - in %1$d d
+ - in %1$d d
+
+
+ - %1$d h overdue
+ - %1$d h overdue
+
+
+ - %1$d d overdue
+ - %1$d d overdue
+
+ Lists
+ A shortcut tile per list, with its open count
+ No lists to show
+ Lists to show
+ Week
+ A seven-day strip of task counts
diff --git a/app/src/main/res/xml/appwidget_info_lists.xml b/app/src/main/res/xml/appwidget_info_lists.xml
new file mode 100644
index 0000000..acde34e
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_lists.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_today.xml b/app/src/main/res/xml/appwidget_info_today.xml
new file mode 100644
index 0000000..db701a6
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_today.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_up_next.xml b/app/src/main/res/xml/appwidget_info_up_next.xml
new file mode 100644
index 0000000..101b93b
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_up_next.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_week.xml b/app/src/main/res/xml/appwidget_info_week.xml
new file mode 100644
index 0000000..273960c
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_week.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt
index 6967c97..9ed7d17 100644
--- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt
+++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt
@@ -10,6 +10,9 @@ fun testTask(
status: TaskStatus = TaskStatus.NEEDS_ACTION,
priority: Priority = Priority.NONE,
due: Instant? = null,
+ isAllDay: Boolean = false,
+ completedAt: Instant? = null,
+ parentId: Long? = null,
): Task = Task(
taskId = id,
listId = listId,
@@ -22,14 +25,14 @@ fun testTask(
percentComplete = null,
start = null,
due = due,
- isAllDay = false,
+ isAllDay = isAllDay,
timeZone = null,
- completedAt = null,
+ completedAt = completedAt,
listColor = 0,
taskColor = null,
listName = null,
accountName = null,
- parentId = null,
+ parentId = parentId,
isRecurring = false,
occurrenceStart = null,
distanceFromCurrent = 0,
diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt
index 61dc4c7..137d9db 100644
--- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt
@@ -1,12 +1,15 @@
package de.jeanlucmakiola.agendula.widget
import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSorting
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.testTask
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
@@ -132,4 +135,99 @@ class WidgetDataTest {
)
assertThat(rows.map { it.title }).containsExactly("a-soon", "a-later", "b-later").inOrder()
}
+
+ // --- sections ----------------------------------------------------------
+
+ private fun row(id: Long, listId: Long = 1, dueKind: DueKind? = null, overdue: Boolean = false) = WidgetTaskRow(
+ taskId = id,
+ listId = listId,
+ occurrenceStart = null,
+ title = "t$id",
+ due = null,
+ isAllDay = false,
+ dueKind = dueKind,
+ isOverdue = overdue,
+ color = 0,
+ )
+
+ @Test
+ fun `due buckets appear in a fixed order and skip empty ones`() {
+ val rows = listOf(
+ row(1, dueKind = DueKind.LATER),
+ row(2, dueKind = DueKind.PAST, overdue = true),
+ row(3, dueKind = DueKind.TODAY),
+ )
+ val sections = widgetSections(rows, groupByList = false, listNames = emptyMap()) { it?.name ?: "none" }
+ assertThat(sections.filterIsInstance().map { it.name })
+ .containsExactly("PAST", "TODAY", "LATER").inOrder()
+ }
+
+ @Test
+ fun `a bucket's rows keep their own order under its one header`() {
+ val rows = listOf(row(1, dueKind = DueKind.TODAY), row(2, dueKind = DueKind.TODAY), row(3, dueKind = DueKind.TODAY))
+ val sections = widgetSections(rows, groupByList = false, listNames = emptyMap()) { "x" }
+ assertThat(sections.filterIsInstance()).hasSize(1)
+ assertThat(sections.filterIsInstance().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder()
+ }
+
+ @Test
+ fun `grouping by list keeps the widgetRows order and headers each run once`() {
+ val rows = listOf(row(1, listId = 1), row(2, listId = 1), row(3, listId = 2))
+ val sections = widgetSections(rows, groupByList = true, listNames = mapOf(1L to "Alpha", 2L to "Beta")) { "x" }
+ assertThat(sections.filterIsInstance().map { it.name }).containsExactly("Alpha", "Beta").inOrder()
+ assertThat(sections.filterIsInstance().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder()
+ }
+
+ // --- per-widget background / theme --------------------------------------
+
+ @Test
+ fun `an unset background is Solid, and round-trips`() {
+ assertThat(parseWidgetBackground(null)).isEqualTo(WidgetBackground.SOLID)
+ WidgetBackground.entries.forEach { assertThat(parseWidgetBackground(it.name)).isEqualTo(it) }
+ assertThat(parseWidgetBackground("garbage")).isEqualTo(WidgetBackground.SOLID)
+ }
+
+ @Test
+ fun `an unset theme override follows the app, Light and Dark pin regardless`() {
+ assertThat(parseWidgetThemeOverride(null)).isNull()
+ assertThat(parseWidgetThemeOverride("garbage")).isNull()
+ assertThat(parseWidgetThemeOverride("LIGHT")).isEqualTo(WidgetThemeMode.LIGHT)
+ assertThat(parseWidgetThemeOverride("DARK")).isEqualTo(WidgetThemeMode.DARK)
+ }
+
+ private fun readyWithTheme(appTheme: ThemeMode) = TaskWidgetData.Ready(
+ filter = DEFAULT_WIDGET_FILTER,
+ listNames = emptyMap(),
+ rows = emptyList(),
+ groupByList = false,
+ dynamicColor = true,
+ themeMode = appTheme,
+ use24Hour = true,
+ )
+
+ @Test
+ fun `no override resolves to whatever the app's own theme means`() {
+ assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
+ assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM)
+ }
+
+ @Test
+ fun `an override wins over the app's own theme`() {
+ assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(WidgetThemeMode.LIGHT)).isEqualTo(WidgetThemeMode.LIGHT)
+ }
+
+ @Test
+ fun `an unset row colour is Neutral, and round-trips`() {
+ assertThat(parseWidgetRowColor(null)).isEqualTo(WidgetRowColor.NEUTRAL)
+ WidgetRowColor.entries.forEach { assertThat(parseWidgetRowColor(it.name)).isEqualTo(it) }
+ assertThat(parseWidgetRowColor("garbage")).isEqualTo(WidgetRowColor.NEUTRAL)
+ }
+
+ @Test
+ fun `overdue count only counts overdue rows`() {
+ val ready = readyWithTheme(ThemeMode.SYSTEM).copy(
+ rows = listOf(row(1, overdue = true), row(2, overdue = false), row(3, overdue = true)),
+ )
+ assertThat(ready.overdueCount).isEqualTo(2)
+ }
}
diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverSchedulerTest.kt
deleted file mode 100644
index 9769a0a..0000000
--- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverSchedulerTest.kt
+++ /dev/null
@@ -1,179 +0,0 @@
-package de.jeanlucmakiola.agendula.widget
-
-import android.content.Intent
-import com.google.common.truth.Truth.assertThat
-import kotlinx.datetime.LocalDate
-import kotlinx.datetime.LocalDateTime
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.atStartOfDayIn
-import kotlinx.datetime.toInstant
-import kotlinx.datetime.toLocalDateTime
-import org.junit.jupiter.api.Test
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Duration.Companion.minutes
-import kotlin.time.Instant
-
-/** The "when is the next local midnight" arithmetic, especially where midnight is not 00:00. */
-class WidgetRolloverSchedulerTest {
-
- private val berlin = TimeZone.of("Europe/Berlin")
-
- private fun at(local: String, zone: TimeZone): Instant =
- LocalDateTime.parse(local).toInstant(zone)
-
- private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
- WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
-
- // --- the ordinary day ----------------------------------------------------
-
- @Test
- fun `midday rolls over at the coming midnight`() {
- val next = nextRollover("2026-08-27T12:00:00")
- assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
- }
-
- @Test
- fun `a second before midnight still targets tonight, not tomorrow night`() {
- val next = nextRollover("2026-08-27T23:59:59")
- assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
- }
-
- @Test
- fun `at midnight exactly the target is the next day, never the current instant`() {
- // Re-arming after a firing must move a whole day on, or the widget wakes
- // itself in a tight loop.
- val now = at("2026-08-28T00:00:00", berlin)
- val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
- assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
- assertThat(next - now).isGreaterThan(Duration.ZERO)
- }
-
- @Test
- fun `re-arming from the slack instant itself moves a full day on`() {
- // What actually happens in practice: the receiver runs at midnight + slack.
- val now = at("2026-08-28T00:00:05", berlin)
- assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
- .isEqualTo(at("2026-08-29T00:00:05", berlin))
- }
-
- @Test
- fun `the result is always in the future for every minute of a day`() {
- val zone = berlin
- // Spans Berlin's 2024 spring-forward: the likeliest source of a target
- // in the past, i.e. an alarm that fires immediately, forever.
- var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
- val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
- while (probe < end) {
- assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
- probe += 1.minutes
- }
- }
-
- // --- daylight saving -----------------------------------------------------
-
- @Test
- fun `spring forward keeps the rollover one day away, not one hour short`() {
- // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
- // A rollover computed as "now + 24h" would land at 01:00 on 1 April.
- val now = at("2024-03-30T12:00:00", berlin)
- val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
- assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
- assertThat(next - now).isLessThan(24.hours)
- }
-
- @Test
- fun `fall back does not overshoot into the repeated hour`() {
- // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
- // "now + 24h" would land at 23:00 on the 26th and never roll over.
- val now = at("2024-10-26T12:00:00", berlin)
- val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
- assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
- assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
- }
-
- @Test
- fun `a zone that repeats midnight takes the first start of day`() {
- // Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
- // began twice. Pinned as the accepted trade: the widget runs an hour
- // ahead until the next redraw. No live zone does this since 2019.
- val saoPaulo = TimeZone.of("America/Sao_Paulo")
- val next = WidgetRolloverScheduler.nextRolloverAt(
- at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
- )
- val local = next.toLocalDateTime(saoPaulo)
- assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
- assertThat(local.hour).isEqualTo(0)
- }
-
- @Test
- fun `a zone where midnight does not exist rolls over at the real start of day`() {
- // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
- // Targeting a literal 00:00 there would arm an instant on the wrong day.
- val havana = TimeZone.of("America/Havana")
- val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
- val local = next.toLocalDateTime(havana)
- assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
- assertThat(local.hour).isEqualTo(1)
- assertThat(local.minute).isEqualTo(0)
- // And it is genuinely the first instant of that date, not a guess.
- assertThat(next).isEqualTo(
- LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
- WidgetRolloverScheduler.ROLLOVER_SLACK,
- )
- }
-
- // --- timezone changes ----------------------------------------------------
-
- @Test
- fun `the same instant rolls over at different times in different zones`() {
- // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
- // follows the zone, not a cached offset.
- val instant = at("2026-08-27T12:00:00", berlin)
- val tokyo = TimeZone.of("Asia/Tokyo")
- val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
- val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
- assertThat(tokyoNext).isNotEqualTo(berlinNext)
- assertThat(tokyoNext).isLessThan(berlinNext)
- assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
- }
-
- @Test
- fun `a half-hour offset zone still lands on its own midnight`() {
- val kathmandu = TimeZone.of("Asia/Kathmandu")
- val next = WidgetRolloverScheduler.nextRolloverAt(
- at("2026-08-27T12:00:00", kathmandu), kathmandu,
- )
- val local = next.toLocalDateTime(kathmandu)
- assertThat(local.date.toString()).isEqualTo("2026-08-28")
- assertThat(local.hour).isEqualTo(0)
- }
-
- // --- the wiring ----------------------------------------------------------
-
- @Test
- fun `the receiver actually handles the action the alarm is sent with`() {
- // The single point where the whole fix would die silently: the alarm
- // fires, the receiver drops it on the action guard, nothing redraws.
- assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
- .contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
- }
-
- @Test
- fun `a language change redraws without re-arming`() {
- assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS).contains(Intent.ACTION_LOCALE_CHANGED)
- assertThat(WidgetUpdateReceiver.REARM_ACTIONS).doesNotContain(Intent.ACTION_LOCALE_CHANGED)
- }
-
- @Test
- fun `everything that loses the alarm re-arms it`() {
- assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
- WidgetUpdateReceiver.ACTION_ROLLOVER,
- Intent.ACTION_BOOT_COMPLETED,
- Intent.ACTION_MY_PACKAGE_REPLACED,
- Intent.ACTION_TIME_CHANGED,
- Intent.ACTION_TIMEZONE_CHANGED,
- Intent.ACTION_DATE_CHANGED,
- )
- }
-}
diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt
new file mode 100644
index 0000000..db25657
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt
@@ -0,0 +1,133 @@
+package de.jeanlucmakiola.agendula.widget
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.agendula.domain.TaskList
+import de.jeanlucmakiola.agendula.domain.TaskSortOrder
+import de.jeanlucmakiola.agendula.domain.TaskStatus
+import de.jeanlucmakiola.agendula.domain.allDayInstantOf
+import de.jeanlucmakiola.agendula.domain.comparator
+import de.jeanlucmakiola.agendula.domain.testTask
+import org.junit.jupiter.api.Test
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.ZoneId
+import kotlin.time.Instant
+
+class WidgetStatsTest {
+
+ private val newYork = ZoneId.of("America/New_York")
+ private val today = LocalDate.of(2026, 7, 20)
+ private val comparator = TaskSortOrder.DUE.comparator()
+
+ private fun at(local: String, zone: ZoneId = newYork): Instant =
+ Instant.fromEpochMilliseconds(LocalDateTime.parse(local).atZone(zone).toInstant().toEpochMilli())
+
+ private val now = at("2026-07-20T12:00:00")
+
+ // --- todayProgress ---------------------------------------------------
+
+ @Test
+ fun `a task completed today counts as done, one completed yesterday does not`() {
+ val completed = listOf(
+ testTask(id = 1, status = TaskStatus.COMPLETED, completedAt = at("2026-07-20T09:00:00")),
+ testTask(id = 2, status = TaskStatus.COMPLETED, completedAt = at("2026-07-19T23:00:00")),
+ )
+ val (done, _, _) = todayProgress(emptyList(), completed, comparator, now, today, newYork)
+ assertThat(done).isEqualTo(1)
+ }
+
+ @Test
+ fun `an all-day task due today and an overdue timed task both count as remaining`() {
+ val open = listOf(
+ testTask(id = 1, due = allDayInstantOf(today), isAllDay = true),
+ testTask(id = 2, due = at("2026-07-20T08:00:00")), // already past `now` (12:00)
+ testTask(id = 3, due = at("2026-07-21T08:00:00")), // tomorrow, not remaining
+ )
+ val (_, remaining, _) = todayProgress(open, emptyList(), comparator, now, today, newYork)
+ assertThat(remaining).isEqualTo(2)
+ }
+
+ @Test
+ fun `an earlier-due remaining task sorts ahead of a later one`() {
+ val open = listOf(
+ 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 (_, _, rows) = todayProgress(open, emptyList(), comparator, now, today, newYork)
+ assertThat(rows.map { it.taskId }).containsExactly(2L, 1L).inOrder()
+ }
+
+ // --- upcoming ----------------------------------------------------------
+
+ @Test
+ 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(upcoming(open, now, today, newYork).map { it.taskId }).containsExactly(3L, 2L).inOrder()
+ }
+
+ @Test
+ fun `upcoming is empty when nothing is dated`() {
+ assertThat(upcoming(listOf(testTask(due = null)), now, today, newYork)).isEmpty()
+ }
+
+ // --- weekDays ------------------------------------------------------------
+
+ @Test
+ fun `today's bucket folds in overdue tasks from earlier days`() {
+ val open = listOf(
+ testTask(id = 1, due = at("2026-07-18T08:00:00")), // two days overdue
+ testTask(id = 2, due = at("2026-07-20T08:00:00")), // due today, already past now
+ )
+ val week = weekDays(open, comparator, now, today, newYork)
+ assertThat(week.first().date).isEqualTo(today)
+ assertThat(week.first().count).isEqualTo(2)
+ assertThat(week.first().overdueCount).isEqualTo(2)
+ }
+
+ @Test
+ fun `a future day only counts tasks due that exact day`() {
+ val open = listOf(
+ testTask(id = 1, due = at("2026-07-22T08:00:00")),
+ testTask(id = 2, due = at("2026-07-23T08:00:00")),
+ )
+ val week = weekDays(open, comparator, now, today, newYork)
+ assertThat(week[2].count).isEqualTo(1) // today + 2 = 07-22
+ assertThat(week[2].overdueCount).isEqualTo(0)
+ assertThat(week[3].count).isEqualTo(1) // today + 3 = 07-23
+ }
+
+ @Test
+ fun `a day at the far edge of daylight saving still buckets by local date`() {
+ // Spans the US spring-forward boundary; only exercised to confirm
+ // calendarDate (not a raw day-of-year add) drives the bucketing.
+ val dstToday = LocalDate.of(2026, 3, 7)
+ val dstNow = at("2026-03-07T12:00:00")
+ val open = listOf(testTask(id = 1, due = at("2026-03-08T08:00:00")))
+ val week = weekDays(open, comparator, dstNow, dstToday, newYork)
+ assertThat(week[1].date).isEqualTo(LocalDate.of(2026, 3, 8))
+ assertThat(week[1].count).isEqualTo(1)
+ }
+
+ // --- listTiles -------------------------------------------------------
+
+ @Test
+ fun `listTiles counts open tasks per visible list and skips a subtask whose parent is also open`() {
+ val lists = listOf(
+ TaskList(1, "Home", 0, "a", "t", false, isVisible = true, owner = null),
+ TaskList(2, "Hidden", 0, "a", "t", false, isVisible = false, owner = null),
+ )
+ val open = listOf(
+ testTask(id = 1, listId = 1),
+ testTask(id = 2, listId = 1, parentId = 1), // subtask of an open parent
+ testTask(id = 3, listId = 2),
+ )
+ val tiles = listTiles(open, lists)
+ assertThat(tiles).hasSize(1)
+ assertThat(tiles.single().id).isEqualTo(1L)
+ assertThat(tiles.single().openCount).isEqualTo(1)
+ }
+}
diff --git a/floret-kit b/floret-kit
index db4be68..cb109c0 160000
--- a/floret-kit
+++ b/floret-kit
@@ -1 +1 @@
-Subproject commit db4be68dad7d5aff2ca16bddfc5cb22fb96ea447
+Subproject commit cb109c0e82f4513a7fb31f898de1c22128508bb5
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 407694a..263000f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -177,6 +177,7 @@ floret-core-locale = { group = "de.jeanlucmakiola.floret", name = "core-locale"
floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" }
floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" }
floret-components = { group = "de.jeanlucmakiola.floret", name = "components" }
+floret-glance = { group = "de.jeanlucmakiola.floret", name = "glance" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }