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