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