diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 40c05bd..2a0182f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -185,18 +185,56 @@
android:resource="@xml/appwidget_info_tasks" />
-
+
+ android:resource="@xml/appwidget_info_today" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/GlanceTaskWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/GlanceTaskWidget.kt
deleted file mode 100644
index bc28270..0000000
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/GlanceTaskWidget.kt
+++ /dev/null
@@ -1,216 +0,0 @@
-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.DpSize
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.glance.ColorFilter
-import androidx.glance.GlanceId
-import androidx.glance.GlanceModifier
-import androidx.glance.GlanceTheme
-import androidx.glance.Image
-import androidx.glance.ImageProvider
-import androidx.glance.LocalContext
-import androidx.glance.LocalSize
-import androidx.glance.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.provideContent
-import androidx.glance.appwidget.state.getAppWidgetState
-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.fillMaxSize
-import androidx.glance.layout.height
-import androidx.glance.layout.padding
-import androidx.glance.layout.size
-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.domain.TaskFilter
-import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
-import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
-import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
-import de.jeanlucmakiola.floret.glance.WidgetActionButton
-import de.jeanlucmakiola.floret.glance.WidgetActionStyle
-import de.jeanlucmakiola.floret.glance.WidgetBackground
-import de.jeanlucmakiola.floret.glance.WidgetSurface
-import de.jeanlucmakiola.floret.glance.WidgetThemeMode
-import de.jeanlucmakiola.floret.glance.R as GlanceR
-import kotlinx.coroutines.flow.first
-
-/**
- * "At a glance" — a small widget that leads with the single thing worth seeing
- * without opening the app: the next open task today, or (too small to show one)
- * just a quick-add button. Always Today + overdue, since a tiny widget has no
- * room for a filter picker of its own — see [TaskWidgetConfigActivity] for
- * background/theme.
- */
-class GlanceTaskWidget : GlanceAppWidget() {
-
- override val stateDefinition = PreferencesGlanceStateDefinition
-
- override val sizeMode = SizeMode.Responsive(setOf(CompactSize, RegularSize))
-
- override suspend fun provideGlance(context: Context, id: GlanceId) {
- val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
- val initial = runCatching { context.taskWidgetData(FILTER, false).first() }
- .getOrDefault(TaskWidgetData.Unavailable)
- provideContent {
- val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
- val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
- val data by remember { context.taskWidgetData(FILTER, false) }.collectAsState(initial = initial)
- val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
- FloretGlanceTheme(
- lightScheme = AgendulaLightFallback,
- darkScheme = AgendulaDarkFallback,
- dynamicColor = ready?.dynamicColor ?: true,
- mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM,
- ) {
- GlanceTaskWidgetBody(data, background)
- }
- }
- }
-
- private companion object {
- val FILTER = TaskFilter.Smart(SmartList.TODAY)
- val CompactSize = DpSize(56.dp, 56.dp)
- val RegularSize = DpSize(110.dp, 110.dp)
- }
-}
-
-@Composable
-private fun GlanceTaskWidgetBody(data: TaskWidgetData?, background: WidgetBackground) {
- val context = LocalContext.current
- val ready = data as? TaskWidgetData.Ready
- val compact = LocalSize.current.width < 90.dp
- WidgetSurface(background = background) {
- if (compact || ready == null) {
- Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- WidgetActionButton(
- icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
- contentDescription = context.getString(R.string.new_task),
- action = actionStartActivity(MainActivity.newTaskIntent(context)),
- style = WidgetActionStyle.FILLED,
- )
- }
- } else {
- Column(
- modifier = GlanceModifier.fillMaxSize().padding(8.dp),
- ) {
- GlanceSummary(ready)
- Spacer(GlanceModifier.height(6.dp))
- val next = ready.rows.firstOrNull()
- if (next == null) {
- Text(
- text = context.getString(R.string.widget_glance_no_tasks),
- style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
- )
- } else {
- GlanceNextTaskRow(next, ready.use24Hour)
- }
- }
- }
- }
-}
-
-@Composable
-private fun GlanceSummary(ready: TaskWidgetData.Ready) {
- val context = LocalContext.current
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = "${ready.rows.size}",
- style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 24.sp, fontWeight = FontWeight.Medium),
- )
- Column(modifier = GlanceModifier.defaultWeight().padding(start = 6.dp)) {
- Text(
- text = context.getString(R.string.smart_today),
- style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp),
- )
- if (ready.overdueCount > 0) {
- Text(
- text = context.resources.getQuantityString(
- R.plurals.widget_glance_overdue_count, ready.overdueCount, ready.overdueCount,
- ),
- style = TextStyle(color = GlanceTheme.colors.error, fontSize = 11.sp),
- )
- }
- }
- WidgetActionButton(
- icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
- contentDescription = context.getString(R.string.new_task),
- action = actionStartActivity(MainActivity.newTaskIntent(context)),
- style = WidgetActionStyle.TONAL,
- )
- }
-}
-
-@Composable
-private fun GlanceNextTaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
- val context = LocalContext.current
- val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
- Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
- Box(
- modifier = GlanceModifier.size(32.dp).clickable(
- actionRunCallback(
- actionParametersOf(
- CompleteTaskAction.TaskIdKey to row.taskId,
- CompleteTaskAction.OccurrenceKey to
- (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
- ),
- ),
- ),
- contentAlignment = Alignment.Center,
- ) {
- Image(
- provider = ImageProvider(GlanceR.drawable.floret_ic_widget_check),
- contentDescription = context.getString(R.string.widget_complete_task, title),
- colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
- modifier = GlanceModifier.size(18.dp),
- )
- }
- Column(
- modifier = GlanceModifier.defaultWeight().padding(start = 4.dp)
- .clickable(
- actionStartActivity(
- MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
- ),
- ),
- ) {
- Text(
- text = title,
- maxLines = 2,
- style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp),
- )
- // This widget is always Today + overdue (see the class doc), and
- // GlanceSummary already labels the group "Today" — so, like the main
- // Tasks widget's own rows, a same-day due only needs its bare time.
- dueText(context, row, use24Hour, suppressDayLabel = true)?.let { due ->
- Text(
- text = due,
- maxLines = 1,
- style = TextStyle(
- color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant,
- fontSize = 11.sp,
- textAlign = TextAlign.Start,
- ),
- )
- }
- }
- }
-}
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..5180dcc
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidget.kt
@@ -0,0 +1,174 @@
+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.DpSize
+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.lazy.LazyColumn
+import androidx.glance.appwidget.lazy.items
+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.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+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
+
+/**
+ * "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
+
+ override val sizeMode = SizeMode.Responsive(setOf(CompactSize, WideSize))
+
+ 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)
+ }
+ }
+ }
+
+ private companion object {
+ val CompactSize = DpSize(110.dp, 110.dp)
+ val WideSize = DpSize(250.dp, 180.dp)
+ }
+}
+
+@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 columns = if (LocalSize.current.width < 250.dp) 2 else 3
+ WidgetSurface(background = background) {
+ Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
+ when {
+ data == null -> Unit
+ ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
+ tiles.isNullOrEmpty() -> WidgetMessage(context.getString(R.string.widget_lists_empty))
+ else -> {
+ val rows = tiles.chunked(columns)
+ LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
+ items(rows.size) { index ->
+ Column {
+ ListTileRow(rows[index], columns)
+ Spacer(GlanceModifier.height(8.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ListTileRow(rowTiles: List, columns: Int) {
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ rowTiles.forEachIndexed { index, tile ->
+ if (index > 0) Spacer(GlanceModifier.width(8.dp))
+ ListTileCell(tile, GlanceModifier.defaultWeight())
+ }
+ repeat(columns - rowTiles.size) {
+ Spacer(GlanceModifier.width(8.dp))
+ Spacer(GlanceModifier.defaultWeight())
+ }
+ }
+}
+
+@Composable
+private fun ListTileCell(tile: ListTile, modifier: GlanceModifier) {
+ val context = LocalContext.current
+ val tint = ColorProvider(day = pastelize(tile.color, false), night = pastelize(tile.color, true))
+ WidgetTile(
+ color = tint,
+ modifier = modifier.height(72.dp),
+ onClick = actionStartActivity(MainActivity.listIntent(context, tile.id)),
+ ) {
+ Column(modifier = GlanceModifier.fillMaxSize()) {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = tile.name,
+ maxLines = 1,
+ modifier = GlanceModifier.defaultWeight(),
+ style = TextStyle(color = GlanceColorProvider(Color.White), fontSize = 13.sp, fontWeight = FontWeight.Medium),
+ )
+ if (tile.acceptsWrites) {
+ WidgetActionButton(
+ icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
+ contentDescription = context.getString(R.string.new_task),
+ action = actionStartActivity(MainActivity.newTaskIntent(context, tile.id)),
+ style = WidgetActionStyle.TONAL,
+ size = 20.dp,
+ )
+ }
+ }
+ Spacer(GlanceModifier.defaultWeight())
+ Text(
+ text = "${tile.openCount}",
+ style = TextStyle(
+ color = GlanceColorProvider(Color.White),
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/GlanceTaskWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidgetReceiver.kt
similarity index 80%
rename from app/src/main/java/de/jeanlucmakiola/agendula/widget/GlanceTaskWidgetReceiver.kt
rename to app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidgetReceiver.kt
index 67e2f65..d88ac77 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/GlanceTaskWidgetReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/ListsWidgetReceiver.kt
@@ -6,9 +6,9 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
-/** Host-facing receiver for [GlanceTaskWidget]; also keeps the midnight rollover armed. */
-class GlanceTaskWidgetReceiver : GlanceAppWidgetReceiver() {
- override val glanceAppWidget: GlanceAppWidget = GlanceTaskWidget()
+/** 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)
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 3b1822d..9ae4119 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt
@@ -10,11 +10,9 @@ 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
@@ -25,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.appwidget.updateAll
import androidx.glance.color.ColorProvider
import androidx.glance.currentState
import androidx.glance.layout.Alignment
@@ -61,6 +57,7 @@ import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetActionButton
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetCheckButton
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetMessage
@@ -131,16 +128,13 @@ class CompleteTaskAction : ActionCallback {
runCatching {
context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true)
}
- // This callback is shared by both TaskWidget's and GlanceTaskWidget's
- // rows, and glanceId only tells us the tapped widget's instance, not
- // which of the two provider classes it belongs to — updating the wrong
- // one by id would push this class's RemoteViews into the other's
- // AppWidget slot. updateAll is class-scoped, so redrawing both is the
- // immediate, correct option; it also covers every other open instance
- // of either widget showing this task.
- val appContext = context.applicationContext
- TaskWidget().updateAll(appContext)
- GlanceTaskWidget().updateAll(appContext)
+ // This callback is shared by every widget's rows, and glanceId only
+ // tells us the tapped widget's instance, not which provider class it
+ // belongs to — updating the wrong one by id would push this class's
+ // RemoteViews into another widget's AppWidget slot. updateAll is
+ // class-scoped, so redrawing every widget is the immediate, correct
+ // option; it also covers every other open instance showing this task.
+ updateAllWidgets(context.applicationContext)
}
companion object {
@@ -296,7 +290,7 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
* neutral tonal fill) reads too dark against them.
*/
@Composable
-private fun AgendaTaskRow(
+internal fun AgendaTaskRow(
row: WidgetTaskRow,
use24Hour: Boolean,
color: GlanceColorProvider,
@@ -316,28 +310,17 @@ private fun AgendaTaskRow(
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Box(
- modifier = GlanceModifier
- .size(28.dp)
- .cornerRadius(14.dp)
- .clickable(
- actionRunCallback(
- actionParametersOf(
- CompleteTaskAction.TaskIdKey to row.taskId,
- CompleteTaskAction.OccurrenceKey to
- (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
- ),
- ),
+ 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(GlanceR.drawable.floret_ic_widget_check),
- contentDescription = context.getString(R.string.widget_complete_task, title),
- colorFilter = ColorFilter.tint(onColorVariant),
- modifier = GlanceModifier.size(16.dp),
- )
- }
+ ),
+ tint = onColorVariant,
+ )
Spacer(GlanceModifier.width(8.dp))
Column(modifier = GlanceModifier.defaultWeight().clickable(open)) {
Text(
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt
index 68d4c5f..858cbd9 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
@@ -58,10 +60,37 @@ 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; 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() {
@@ -79,6 +108,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())
@@ -92,6 +122,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
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])
@@ -99,6 +130,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY])
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()
@@ -108,6 +140,7 @@ 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,
@@ -119,17 +152,28 @@ class TaskWidgetConfigActivity : ComponentActivity() {
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 -> {
+ selectedListIds?.let { ids -> it[WIDGET_LIST_IDS_KEY] = ids.widgetListIdsStorageValue() }
+ ?: it.remove(WIDGET_LIST_IDS_KEY)
+ }
+ WidgetKind.TODAY, WidgetKind.UP_NEXT, WidgetKind.WEEK -> Unit
+ }
it[WIDGET_BACKGROUND_KEY] = background.name
themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name }
?: it.remove(WIDGET_THEME_KEY)
- it[WIDGET_ROW_COLOR_KEY] = rowColor.name
}
- TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
+ widgetFor(kind).update(this@TaskWidgetConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId))
finish()
}
@@ -146,6 +190,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
@Composable
private fun WidgetFilterPicker(
+ kind: WidgetKind,
lists: List,
selected: TaskFilter,
dark: Boolean,
@@ -157,6 +202,8 @@ private fun WidgetFilterPicker(
onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
rowColor: WidgetRowColor,
onRowColorChange: (WidgetRowColor) -> Unit,
+ selectedListIds: Set?,
+ onSelectedListIdsChange: (Set?) -> Unit,
onSave: (TaskFilter) -> Unit,
onDismiss: () -> Unit,
) {
@@ -177,10 +224,10 @@ 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(
@@ -197,21 +244,51 @@ private fun WidgetFilterPicker(
) { 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),
@@ -219,41 +296,40 @@ 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))
- 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))
}
- Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.widget_config_background),
summary = stringResource(backgroundLabel(background)),
position = Position.Top,
onClick = { showBackgroundPicker = true },
)
- 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 },
- )
+ 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) {
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 c313b9c..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,7 +1,6 @@
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
@@ -52,8 +51,7 @@ class TaskWidgetUpdater @Inject constructor(
private suspend fun redraw() {
runCatching {
if (!WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) return@runCatching
- TaskWidget().updateAll(context)
- GlanceTaskWidget().updateAll(context)
+ 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..1020c50
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TodayWidget.kt
@@ -0,0 +1,185 @@
+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.DpSize
+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.provideContent
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+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.WidgetActionButton
+import de.jeanlucmakiola.floret.glance.WidgetActionStyle
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetHeader
+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 the next open task shown
+ * below it once the widget is tall enough to carry one. Complements
+ * [TaskWidget] rather than repeating it: a glance stat, not another list.
+ */
+class TodayWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ override val sizeMode = SizeMode.Responsive(setOf(CompactSize, TallSize))
+
+ 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,
+ ) {
+ TodayWidgetBody(data, background)
+ }
+ }
+ }
+
+ private companion object {
+ val CompactSize = DpSize(110.dp, 110.dp)
+ val TallSize = DpSize(110.dp, 180.dp)
+ }
+}
+
+@Composable
+private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
+ val context = LocalContext.current
+ val ready = data as? WidgetStatsData.Ready
+ val tall = LocalSize.current.height >= 150.dp
+ WidgetSurface(background = background) {
+ Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
+ WidgetHeader(
+ title = context.getString(R.string.smart_today),
+ titleAction = actionStartActivity(MainActivity.todayIntent(context)),
+ ) {
+ WidgetActionButton(
+ icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
+ contentDescription = context.getString(R.string.new_task),
+ action = actionStartActivity(MainActivity.newTaskIntent(context)),
+ style = WidgetActionStyle.FILLED,
+ size = 32.dp,
+ )
+ }
+ Spacer(GlanceModifier.height(4.dp))
+ when {
+ data == null -> Unit
+ ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
+ else -> {
+ val ringAreaModifier = GlanceModifier.fillMaxWidth()
+ .let { if (tall) it.defaultWeight() else it }
+ .clickable(actionStartActivity(MainActivity.todayIntent(context)))
+ Box(modifier = ringAreaModifier, contentAlignment = Alignment.Center) {
+ TodayRing(ready)
+ }
+ if (tall) {
+ Spacer(GlanceModifier.height(6.dp))
+ val next = ready.snapshot.todayNext
+ if (next != null) {
+ AgendaTaskRow(
+ row = next,
+ use24Hour = ready.snapshot.use24Hour,
+ color = GlanceTheme.colors.surfaceVariant,
+ colored = false,
+ suppressDayLabel = true,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TodayRing(ready: WidgetStatsData.Ready) {
+ 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
+ WidgetProgressRing(
+ progress = if (total == 0) 0f else progress,
+ size = 72.dp,
+ trackColor = GlanceTheme.colors.surfaceVariant,
+ indicatorColor = indicator,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ if (total == 0) {
+ Text(
+ text = context.getString(R.string.widget_today_nothing_due),
+ maxLines = 2,
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 11.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.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ } else {
+ Text(
+ text = "${snapshot.todayDone}/$total",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ }
+ }
+}
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..88e4747
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/UpNextWidget.kt
@@ -0,0 +1,194 @@
+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.DpSize
+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.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.action.actionParametersOf
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.provideContent
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+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.glance.FloretGlanceTheme
+import de.jeanlucmakiola.floret.glance.WidgetActionButton
+import de.jeanlucmakiola.floret.glance.WidgetActionStyle
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetCheckButton
+import de.jeanlucmakiola.floret.glance.WidgetMessage
+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 kotlin.time.Clock
+import kotlin.time.Instant
+
+/**
+ * "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.
+ */
+class UpNextWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ override val sizeMode = SizeMode.Responsive(setOf(CompactSize, WideSize))
+
+ 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,
+ ) {
+ UpNextWidgetBody(data, background)
+ }
+ }
+ }
+
+ private companion object {
+ val CompactSize = DpSize(110.dp, 56.dp)
+ val WideSize = DpSize(250.dp, 56.dp)
+ }
+}
+
+@Composable
+private fun UpNextWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
+ val context = LocalContext.current
+ val ready = data as? WidgetStatsData.Ready
+ val row = ready?.snapshot?.nextDue
+ WidgetSurface(background = background) {
+ when {
+ data == null -> Unit
+ ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
+ row == null -> UpNextEmpty(context)
+ else -> UpNextRow(row, ready.snapshot.use24Hour)
+ }
+ }
+}
+
+@Composable
+private fun UpNextEmpty(context: Context) {
+ 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)),
+ style = WidgetActionStyle.FILLED,
+ size = 36.dp,
+ )
+ }
+}
+
+@Composable
+private fun UpNextRow(row: WidgetTaskRow, use24Hour: Boolean) {
+ val context = LocalContext.current
+ val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
+ val now = Clock.System.now()
+ Row(
+ modifier = GlanceModifier.fillMaxSize().padding(8.dp)
+ .clickable(
+ actionStartActivity(
+ MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
+ ),
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ WidgetCheckButton(
+ contentDescription = context.getString(R.string.widget_complete_task, title),
+ action = actionRunCallback(
+ actionParametersOf(
+ CompleteTaskAction.TaskIdKey to row.taskId,
+ CompleteTaskAction.OccurrenceKey to
+ (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
+ ),
+ ),
+ tint = GlanceTheme.colors.onSurfaceVariant,
+ )
+ Spacer(GlanceModifier.width(8.dp))
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ Text(
+ text = title,
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium),
+ )
+ Text(
+ text = upNextLabel(context, row, use24Hour, now),
+ maxLines = 1,
+ style = TextStyle(
+ color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp,
+ ),
+ )
+ }
+ }
+}
+
+/**
+ * The hero line under [row]'s title: a bare time when due today, "Tomorrow",
+ * or a coarse relative count otherwise — this widget only redraws on data
+ * changes, at midnight, and on the half-hourly `updatePeriodMillis`, so a
+ * minute-level countdown would just look wrong between redraws.
+ */
+internal fun upNextLabel(context: Context, row: WidgetTaskRow, use24Hour: Boolean, now: Instant): String {
+ val due = row.due ?: return ""
+ if (row.dueKind == DueKind.TODAY && !row.isOverdue) return due.formatTime(use24Hour)
+ 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 -> {
+ val days = hours / HOURS_PER_DAY
+ context.resources.getQuantityString(R.plurals.widget_up_next_in_days, days.toInt(), days.toInt())
+ }
+ }
+}
+
+private const val HOURS_PER_DAY = 24L
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..0e8e83a
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WeekWidget.kt
@@ -0,0 +1,152 @@
+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.DpSize
+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.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.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+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.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 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
+
+ override val sizeMode = SizeMode.Responsive(setOf(CompactSize, TallSize))
+
+ 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)
+ }
+ }
+ }
+
+ private companion object {
+ val CompactSize = DpSize(250.dp, 56.dp)
+ val TallSize = DpSize(250.dp, 140.dp)
+ }
+}
+
+@Composable
+private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
+ val context = LocalContext.current
+ val ready = data as? WidgetStatsData.Ready
+ val tall = LocalSize.current.height >= 110.dp
+ WidgetSurface(background = background) {
+ when {
+ data == null -> Unit
+ ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
+ else -> {
+ Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
+ ready.snapshot.week.forEachIndexed { index, day ->
+ if (index > 0) Spacer(GlanceModifier.width(4.dp))
+ WeekDayCell(day, tall, GlanceModifier.defaultWeight())
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun WeekDayCell(day: WeekDay, tall: Boolean, 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))
+ }
+ WidgetTile(color = background, modifier = modifier, onClick = open) {
+ Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
+ val locale = context.resources.configuration.locales[0] ?: Locale.ROOT
+ Text(
+ text = day.date.dayOfWeek.getDisplayName(JavaTextStyle.NARROW, locale),
+ style = TextStyle(color = onBackground, fontSize = 11.sp, textAlign = TextAlign.Center),
+ )
+ Text(
+ text = "${day.date.dayOfMonth}",
+ style = TextStyle(color = onBackground, fontSize = 13.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
+ )
+ Spacer(GlanceModifier.defaultWeight())
+ if (day.count > 0) {
+ Text(
+ text = "${day.count}",
+ style = TextStyle(
+ color = if (day.overdueCount > 0) GlanceTheme.colors.error else onBackground,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ }
+ if (tall) {
+ day.first?.let { row ->
+ Text(
+ text = row.title.ifBlank { "" },
+ maxLines = 1,
+ style = TextStyle(color = onBackground, fontSize = 10.sp, textAlign = TextAlign.Center),
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun isToday(date: LocalDate): Boolean = date == java.time.LocalDate.now()
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/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt
index d37ffe6..796470f 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt
@@ -36,6 +36,15 @@ 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. */
@@ -52,7 +61,11 @@ internal fun parseWidgetThemeOverride(raw: String?): WidgetThemeMode? =
raw?.let { runCatching { WidgetThemeMode.valueOf(it) }.getOrNull() }
internal fun TaskWidgetData.Ready.effectiveThemeMode(override: WidgetThemeMode?): WidgetThemeMode =
- override ?: when (themeMode) {
+ 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
@@ -219,23 +232,22 @@ internal fun widgetRows(
it.sortedWith(compareBy({ task -> listNames[task.listId].orEmpty() }, { task -> task.listId }))
} 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,
- )
- }
+ 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/WidgetStats.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt
new file mode 100644
index 0000000..0a5b8c1
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetStats.kt
@@ -0,0 +1,147 @@
+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 todayNext: WidgetTaskRow?,
+ val nextDue: WidgetTaskRow?,
+ 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
+}
+
+/**
+ * How far through today a set of [open] and [completed] tasks says you are:
+ * tasks done today (by [Task.completedAt]'s local date), tasks still open that
+ * are overdue or due today (in [comparator] order), and the first of those.
+ */
+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.firstOrNull()?.toWidgetRow(now, today, zone))
+}
+
+/** The open task with the earliest due moment, overdue included — `null` when nothing is dated. */
+internal fun nextDue(open: List, now: Instant, today: LocalDate, zone: ZoneId): WidgetTaskRow? =
+ open.filter { it.due != null }.minByOrNull { it.due!! }?.toWidgetRow(now, today, zone)
+
+/**
+ * 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, next) = todayProgress(open, completed, comparator, now, today, zone)
+ val data: WidgetStatsData = WidgetStatsData.Ready(
+ WidgetStatsSnapshot(
+ todayDone = done,
+ todayRemaining = remaining,
+ todayNext = next,
+ nextDue = nextDue(open, now, today, zone),
+ 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/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt
index af0d21e..c49ff28 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt
@@ -7,9 +7,23 @@ import androidx.glance.appwidget.updateAll
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
-/** Both home-screen widgets' receiver classes — the set [WidgetRollover] checks for placed instances. */
-internal val WIDGET_RECEIVER_CLASSES: List> =
- listOf(TaskWidgetReceiver::class.java, GlanceTaskWidgetReceiver::class.java)
+/** 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"
@@ -23,7 +37,7 @@ internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = Pend
)
/**
- * Redraws both widgets when their day or language goes stale, and keeps the
+ * 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].
@@ -35,7 +49,6 @@ class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
override suspend fun redraw(context: Context) {
- TaskWidget().updateAll(context)
- GlanceTaskWidget().updateAll(context)
+ updateAllWidgets(context)
}
}
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_glance.xml b/app/src/main/res/layout/widget_preview_glance.xml
deleted file mode 100644
index 9395daf..0000000
--- a/app/src/main/res/layout/widget_preview_glance.xml
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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_today.xml b/app/src/main/res/layout/widget_preview_today.xml
new file mode 100644
index 0000000..51e74d8
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_today.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
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 ce183df..9e4bde5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -783,11 +783,39 @@
Every row uses the same tonal fill
By list
Each row is tinted with its list\'s own colour
- At a glance
- Your next task, or a quick way to add one
- Nothing due today
-
- - %1$d overdue
- - %1$d overdue
+ 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
+
+ - 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_glance.xml b/app/src/main/res/xml/appwidget_info_glance.xml
deleted file mode 100644
index 8aa1523..0000000
--- a/app/src/main/res/xml/appwidget_info_glance.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
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..0259b5a
--- /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..4040c9e
--- /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..a7c5984
--- /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/WidgetStatsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetStatsTest.kt
new file mode 100644
index 0000000..d0083a1
--- /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 (_, _, next) = todayProgress(open, emptyList(), comparator, now, today, newYork)
+ assertThat(next?.taskId).isEqualTo(2)
+ }
+
+ // --- nextDue -----------------------------------------------------------
+
+ @Test
+ fun `nextDue picks the earliest due task, overdue included, and skips undated ones`() {
+ val open = listOf(
+ testTask(id = 1, due = null),
+ testTask(id = 2, due = at("2026-07-21T08:00:00")),
+ testTask(id = 3, due = at("2026-07-20T08:00:00")), // overdue, earliest
+ )
+ assertThat(nextDue(open, now, today, newYork)?.taskId).isEqualTo(3)
+ }
+
+ @Test
+ fun `nextDue is null when nothing is dated`() {
+ assertThat(nextDue(listOf(testTask(due = null)), now, today, newYork)).isNull()
+ }
+
+ // --- 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 435447e..d7010b0 160000
--- a/floret-kit
+++ b/floret-kit
@@ -1 +1 @@
-Subproject commit 435447eae266b4460038f8db2b9557303343fe4d
+Subproject commit d7010b0b82d1e57ad3541c914393be5438a3edc9