feat(widget): scrap 'At a glance', add Today/Up next/Lists/Week

'At a glance' predated the Tasks widget's M3 Expressive redesign and
only duplicated a subset of what Tasks already shows (today's tasks),
so it's gone rather than patched up. In its place, four widgets that
each do something Tasks structurally can't:

- Today: a progress ring for done vs. still-open-today tasks
- Up next: the single nearest due task, as a countdown
- Lists: a shortcut tile per list, with its own open count
- Week: a seven-day strip of per-day task counts

Shared data lives in WidgetStats.kt (pure, unit-tested); the config
activity is now kind-aware per widget provider, and the task-row
building blocks (AgendaTaskRow, the row mapper) are shared rather than
duplicated. Needs the floret-kit bump for WidgetProgressRing/WidgetTile
/WidgetCheckButton.
This commit is contained in:
2026-10-02 18:19:02 +02:00
parent 7b1b8bb33e
commit bf29c0e2c4
31 changed files with 1593 additions and 412 deletions
+43 -5
View File
@@ -185,18 +185,56 @@
android:resource="@xml/appwidget_info_tasks" />
</receiver>
<!-- "At a glance" home-screen widget (Glance): the next task, or a quick-add
button when too small to show one. -->
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
<receiver
android:name=".widget.GlanceTaskWidgetReceiver"
android:label="@string/widget_glance_label"
android:name=".widget.TodayWidgetReceiver"
android:label="@string/widget_today_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_glance" />
android:resource="@xml/appwidget_info_today" />
</receiver>
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
<receiver
android:name=".widget.UpNextWidgetReceiver"
android:label="@string/widget_up_next_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_up_next" />
</receiver>
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
<receiver
android:name=".widget.ListsWidgetReceiver"
android:label="@string/widget_lists_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_lists" />
</receiver>
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
<receiver
android:name=".widget.WeekWidgetReceiver"
android:label="@string/widget_week_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_week" />
</receiver>
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
@@ -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<CompleteTaskAction>(
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,
),
)
}
}
}
}
@@ -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<Long>?) {
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<ListTile>, columns: Int) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
rowTiles.forEachIndexed { index, tile ->
if (index > 0) Spacer(GlanceModifier.width(8.dp))
ListTileCell(tile, GlanceModifier.defaultWeight())
}
repeat(columns - rowTiles.size) {
Spacer(GlanceModifier.width(8.dp))
Spacer(GlanceModifier.defaultWeight())
}
}
}
@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,
),
)
}
}
}
@@ -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)
@@ -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<CompleteTaskAction>(
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<CompleteTaskAction>(
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(
@@ -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<WidgetThemeMode?>(null) }
var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
var selectedListIds by remember { mutableStateOf<Set<Long>?>(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<TaskList>,
selected: TaskFilter,
dark: Boolean,
@@ -157,6 +202,8 @@ private fun WidgetFilterPicker(
onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
rowColor: WidgetRowColor,
onRowColorChange: (WidgetRowColor) -> Unit,
selectedListIds: Set<Long>?,
onSelectedListIdsChange: (Set<Long>?) -> 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) {
@@ -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)
}
}
@@ -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,
),
)
}
}
}
}
@@ -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)
}
}
@@ -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<CompleteTaskAction>(
actionParametersOf(
CompleteTaskAction.TaskIdKey to row.taskId,
CompleteTaskAction.OccurrenceKey to
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
),
),
tint = GlanceTheme.colors.onSurfaceVariant,
)
Spacer(GlanceModifier.width(8.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
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
@@ -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)
}
}
@@ -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()
@@ -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)
}
}
@@ -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<Long>? =
raw?.split(",")?.mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }?.toSet()?.takeIf { it.isNotEmpty() }
internal fun Set<Long>.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
@@ -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<WeekDay>,
val lists: List<ListTile>,
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<Task>,
completed: List<Task>,
comparator: Comparator<Task>,
now: Instant,
today: LocalDate,
zone: ZoneId,
): Triple<Int, Int, WidgetTaskRow?> {
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<Task>, 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<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> =
(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<Task>, lists: List<TaskList>): List<ListTile> {
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<WidgetStatsData> {
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) }
}
@@ -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<Class<*>> =
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<Class<*>> = 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)
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock of WidgetProgressRing for the widget-picker preview. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke android:width="7dp" android:color="@color/widget_preview_primary" />
</shape>
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout); the live
widget is rendered by Glance. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/preview_widget_bg"
android:padding="10dp"
tools:ignore="HardcodedText,UseAppTint">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="3"
android:textColor="@color/widget_preview_primary"
android:textSize="24sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/smart_today"
android:textColor="@color/widget_preview_on_surface"
android:textSize="12sp" />
</LinearLayout>
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:padding="9dp"
android:contentDescription="@null"
android:background="@drawable/preview_add_bg"
android:src="@drawable/floret_ic_widget_add"
android:tint="@color/widget_preview_on_primary_container" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Water the plants"
android:textColor="@color/widget_preview_on_surface"
android:textSize="13sp" />
</LinearLayout>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout); the live
widget is rendered by Glance. -->
<GridLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:padding="8dp"
android:columnCount="2"
android:rowCount="2"
tools:ignore="HardcodedText">
<TextView
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_columnWeight="1"
android:layout_rowWeight="1"
android:layout_margin="4dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="#B48A9C"
android:padding="8dp"
android:gravity="bottom"
android:text="Home\n3"
android:textColor="@android:color/white"
android:textSize="13sp" />
<TextView
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_columnWeight="1"
android:layout_rowWeight="1"
android:layout_margin="4dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="#7FA3C9"
android:padding="8dp"
android:gravity="bottom"
android:text="Work\n5"
android:textColor="@android:color/white"
android:textSize="13sp" />
</GridLayout>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout); the live
widget is rendered by Glance. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/preview_widget_bg"
android:padding="10dp"
tools:ignore="HardcodedText,UseAppTint">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/smart_today"
android:textColor="@color/widget_preview_primary"
android:textSize="16sp"
android:textStyle="bold" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="4dp">
<ImageView
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center"
android:contentDescription="@null"
android:background="@drawable/preview_ring" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="4/6"
android:textColor="@color/widget_preview_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout); the live
widget is rendered by Glance. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/preview_widget_bg"
android:padding="10dp"
tools:ignore="HardcodedText,UseAppTint">
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_marginEnd="9dp"
android:contentDescription="@null"
android:src="@drawable/floret_ic_widget_check"
android:tint="@color/widget_preview_variant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Call the dentist"
android:textColor="@color/widget_preview_on_surface"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="in 3 h"
android:textColor="@color/widget_preview_variant"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout); the live
widget is rendered by Glance. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:background="@drawable/preview_widget_bg"
android:padding="6dp"
tools:ignore="HardcodedText">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp"
android:background="@color/widget_preview_primary_container"
android:gravity="center"
android:text="M\n17\n2"
android:textColor="@color/widget_preview_on_primary_container"
android:textSize="11sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp"
android:background="@color/widget_preview_surface"
android:gravity="center"
android:text="T\n18\n1"
android:textColor="@color/widget_preview_variant"
android:textSize="11sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp"
android:background="@color/widget_preview_surface"
android:gravity="center"
android:text="W\n19\n·"
android:textColor="@color/widget_preview_variant"
android:textSize="11sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp"
android:background="@color/widget_preview_surface"
android:gravity="center"
android:text="T\n20\n3"
android:textColor="@color/widget_preview_variant"
android:textSize="11sp" />
</LinearLayout>
+34 -6
View File
@@ -783,11 +783,39 @@
<string name="widget_config_row_color_neutral_hint">Every row uses the same tonal fill</string>
<string name="widget_config_row_color_by_list">By list</string>
<string name="widget_config_row_color_by_list_hint">Each row is tinted with its list\'s own colour</string>
<string name="widget_glance_label">At a glance</string>
<string name="widget_glance_description">Your next task, or a quick way to add one</string>
<string name="widget_glance_no_tasks">Nothing due today</string>
<plurals name="widget_glance_overdue_count">
<item quantity="one">%1$d overdue</item>
<item quantity="other">%1$d overdue</item>
<string name="widget_today_label">Today</string>
<string name="widget_today_description">A progress ring for today\'s tasks</string>
<string name="widget_today_all_done">All done</string>
<string name="widget_today_nothing_due">Nothing due today</string>
<plurals name="widget_today_progress_cd">
<item quantity="one">%1$d of %2$d done</item>
<item quantity="other">%1$d of %2$d done</item>
</plurals>
<string name="widget_up_next_label">Up next</string>
<string name="widget_up_next_description">Your single nearest due task</string>
<string name="widget_up_next_empty">Nothing due</string>
<string name="widget_up_next_soon">Soon</string>
<string name="widget_up_next_tomorrow">Tomorrow</string>
<plurals name="widget_up_next_in_hours">
<item quantity="one">in %1$d h</item>
<item quantity="other">in %1$d h</item>
</plurals>
<plurals name="widget_up_next_in_days">
<item quantity="one">in %1$d d</item>
<item quantity="other">in %1$d d</item>
</plurals>
<plurals name="widget_up_next_overdue_hours">
<item quantity="one">%1$d h overdue</item>
<item quantity="other">%1$d h overdue</item>
</plurals>
<plurals name="widget_up_next_overdue_days">
<item quantity="one">%1$d d overdue</item>
<item quantity="other">%1$d d overdue</item>
</plurals>
<string name="widget_lists_label">Lists</string>
<string name="widget_lists_description">A shortcut tile per list, with its open count</string>
<string name="widget_lists_empty">No lists to show</string>
<string name="widget_lists_config_title">Lists to show</string>
<string name="widget_week_label">Week</string>
<string name="widget_week_description">A seven-day strip of task counts</string>
</resources>
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="56dp"
android:minHeight="56dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:minResizeWidth="56dp"
android:minResizeHeight="56dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_glance_description"
android:updatePeriodMillis="1800000"
android:widgetFeatures="configuration_optional"
android:previewLayout="@layout/widget_preview_glance"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_lists_description"
android:updatePeriodMillis="1800000"
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_preview_lists"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:resizeMode="vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_today_description"
android:updatePeriodMillis="1800000"
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_preview_today"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="56dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:minResizeWidth="110dp"
android:minResizeHeight="56dp"
android:resizeMode="horizontal"
android:widgetCategory="home_screen"
android:description="@string/widget_up_next_description"
android:updatePeriodMillis="1800000"
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_preview_up_next"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="56dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:minResizeWidth="250dp"
android:minResizeHeight="56dp"
android:resizeMode="vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_week_description"
android:updatePeriodMillis="1800000"
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_preview_week"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -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,
@@ -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)
}
}