feat(widget): widget redesign and new Today/Up next/Lists/Week widgets (#33)
Reworks the home-screen widgets on top of #32. - Tasks widget: agenda-style rows, a row-colour setting, an "All caught up" empty state - Drops "At a glance" (it duplicated the Tasks widget) for four new widgets: Today (progress ring + today's tasks), Up next (the nearest due task, optionally from one list), Lists (adaptive shortcut grid), Week (day strip with counts) - Ticking a task in any widget shows a done frame before the row goes away - Shared Glance primitives live in floret-kit (`feat/glance-module`): progress ring, tiles, check button, header action pill, expressive shapes, done state Today and Up next don't offer the transparent background, since their content isn't in containers and is unreadable on the wallpaper. No issue behind this one; it came out of reviewing the widgets on-device. Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/33
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -185,6 +185,58 @@
|
||||
android:resource="@xml/appwidget_info_tasks" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
|
||||
<receiver
|
||||
android:name=".widget.TodayWidgetReceiver"
|
||||
android:label="@string/widget_today_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_today" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
|
||||
<receiver
|
||||
android:name=".widget.UpNextWidgetReceiver"
|
||||
android:label="@string/widget_up_next_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_up_next" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
|
||||
<receiver
|
||||
android:name=".widget.ListsWidgetReceiver"
|
||||
android:label="@string/widget_lists_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_lists" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
|
||||
<receiver
|
||||
android:name=".widget.WeekWidgetReceiver"
|
||||
android:label="@string/widget_week_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_week" />
|
||||
</receiver>
|
||||
|
||||
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
||||
<activity
|
||||
android:name=".widget.TaskWidgetConfigActivity"
|
||||
|
||||
@@ -186,6 +186,7 @@ class MainActivity : ComponentActivity() {
|
||||
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||
private const val SHARED_TITLE_LIMIT = 500
|
||||
private const val NO_OCCURRENCE = -1L
|
||||
|
||||
@@ -206,9 +207,14 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/** The launcher shortcut's and Quick Settings tile's "New task". */
|
||||
fun newTaskIntent(context: Context): Intent =
|
||||
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
|
||||
/**
|
||||
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
||||
* task". [listId] preset the list when it names exactly one; the data
|
||||
* URI keeps each widget's PendingIntent apart from the others.
|
||||
*/
|
||||
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
|
||||
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
fun todayIntent(context: Context): Intent =
|
||||
@@ -233,7 +239,9 @@ class MainActivity : ComponentActivity() {
|
||||
?.let { return NavRequest.OpenAccount(it) }
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||
when (intent.action) {
|
||||
ACTION_NEW_TASK -> 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 } }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Long>?) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? WidgetStatsData.Ready
|
||||
val tiles = ready?.snapshot?.lists?.let { lists -> listIds?.let { ids -> lists.filter { it.id in ids } } ?: lists }
|
||||
val 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Long>("taskId")
|
||||
val OccurrenceKey = ActionParameters.Key<Long>("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<WidgetTaskRow>, listNames: Map<Long, String>): List<WidgetListItem> {
|
||||
val result = mutableListOf<WidgetListItem>()
|
||||
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<CompleteTaskAction>(
|
||||
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<CompleteTaskAction>(
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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<TaskFilter?>(null) }
|
||||
var groupByList by remember { mutableStateOf(false) }
|
||||
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
|
||||
var themeOverride by remember { mutableStateOf<WidgetThemeMode?>(null) }
|
||||
var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
|
||||
var selectedListIds by remember { mutableStateOf<Set<Long>?>(null) }
|
||||
LaunchedEffect(glanceId) {
|
||||
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
||||
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
|
||||
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<TaskList>,
|
||||
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<Long>?,
|
||||
onSelectedListIdsChange: (Set<Long>?) -> 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<WidgetThemeMode?>(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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [TodayWidget]; also keeps the midnight rollover armed. */
|
||||
class TodayWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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<WidgetTaskRow>, 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<CompleteTaskAction>(
|
||||
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
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [UpNextWidget]; also keeps the midnight rollover armed. */
|
||||
class UpNextWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = UpNextWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Set<String>>(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
|
||||
}
|
||||
@@ -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<Long>? =
|
||||
raw?.split(",")?.mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }?.toSet()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
internal fun Set<Long>.widgetListIdsStorageValue(): String = joinToString(",")
|
||||
|
||||
internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY)
|
||||
|
||||
/** How a row's tonal container is tinted: the neutral surface role, or the task's own list colour. */
|
||||
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<WidgetBackground> = 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<WidgetTaskRow>)
|
||||
|
||||
/** Each [WidgetRun] as its own [WidgetSection.Header] followed by its rows. */
|
||||
private fun headedRuns(runs: List<WidgetRun>): List<WidgetSection> =
|
||||
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<WidgetTaskRow>,
|
||||
groupByList: Boolean,
|
||||
listNames: Map<Long, String>,
|
||||
dueBucketLabel: (DueKind?) -> String,
|
||||
): List<WidgetSection> {
|
||||
if (groupByList) {
|
||||
val runs = mutableListOf<WidgetRun>()
|
||||
var current: MutableList<WidgetTaskRow>? = 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
|
||||
|
||||
@@ -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<AlarmManager>() ?: 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
|
||||
}
|
||||
@@ -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<WidgetTaskRow>,
|
||||
/** Open dated tasks, earliest due first — Up next shows the head, optionally narrowed to one list. */
|
||||
val upcoming: List<WidgetTaskRow>,
|
||||
val week: List<WeekDay>,
|
||||
val lists: List<ListTile>,
|
||||
val dynamicColor: Boolean,
|
||||
val themeMode: ThemeMode,
|
||||
val use24Hour: Boolean,
|
||||
)
|
||||
|
||||
internal sealed interface WidgetStatsData {
|
||||
/** No readable store yet, mirroring [TaskWidgetData.Unavailable]. */
|
||||
data object Unavailable : WidgetStatsData
|
||||
data class Ready(val snapshot: WidgetStatsSnapshot) : WidgetStatsData
|
||||
}
|
||||
|
||||
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<Task>,
|
||||
completed: List<Task>,
|
||||
comparator: Comparator<Task>,
|
||||
now: Instant,
|
||||
today: LocalDate,
|
||||
zone: ZoneId,
|
||||
): Triple<Int, Int, List<WidgetTaskRow>> {
|
||||
val done = completed.count { task ->
|
||||
val at = task.completedAt ?: return@count false
|
||||
at.toLocalDate(zone) == today
|
||||
}
|
||||
val remaining = open.filter { task ->
|
||||
val due = task.due ?: return@filter false
|
||||
isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY
|
||||
}.sortedWith(comparator)
|
||||
return Triple(done, remaining.size, remaining.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<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WidgetTaskRow> =
|
||||
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<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> =
|
||||
(0..6L).map { offset ->
|
||||
val date = today.plusDays(offset)
|
||||
val dayTasks = open.filter { task ->
|
||||
val due = task.due ?: return@filter false
|
||||
if (offset == 0L) {
|
||||
isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date
|
||||
} else {
|
||||
due.calendarDate(task.isAllDay, zone) == date
|
||||
}
|
||||
}
|
||||
val overdueCount = if (offset == 0L) {
|
||||
dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) }
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone)
|
||||
WeekDay(date, dayTasks.size, overdueCount, first)
|
||||
}
|
||||
|
||||
/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */
|
||||
internal fun listTiles(open: List<Task>, lists: List<TaskList>): List<ListTile> {
|
||||
val present = open.mapTo(HashSet()) { it.taskId }
|
||||
val counted = open.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
|
||||
val counts = counted.groupingBy { it.listId }.eachCount()
|
||||
return lists.filter { it.isVisible }.map { list ->
|
||||
ListTile(list.id, list.name, list.color, counts[list.id] ?: 0, list.acceptsWrites)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Instant.toLocalDate(zone: ZoneId): LocalDate =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||
|
||||
/**
|
||||
* Live stats for the Today, Up next, Lists and Week widgets — the ALL and
|
||||
* COMPLETED smart lists, filtered the same way the app's own smart lists are
|
||||
* ([de.jeanlucmakiola.agendula.data.prefs.Settings.visibleIn]).
|
||||
*/
|
||||
internal fun Context.widgetStats(): Flow<WidgetStatsData> {
|
||||
val ep = widgetEntryPoint()
|
||||
val repository = ep.tasksRepository()
|
||||
return combine(
|
||||
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||
repository.taskLists(),
|
||||
ep.settingsPrefs().settings,
|
||||
) { openRaw, completedRaw, lists, settings ->
|
||||
val open = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), openRaw)
|
||||
val completed = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), completedRaw)
|
||||
val now = Clock.System.now()
|
||||
val zone = ZoneId.systemDefault()
|
||||
val today = now.toLocalDate(zone)
|
||||
val comparator = settings.sortOrder.comparator()
|
||||
val (done, remaining, 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) }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<Class<*>> = listOf(
|
||||
TaskWidgetReceiver::class.java,
|
||||
TodayWidgetReceiver::class.java,
|
||||
UpNextWidgetReceiver::class.java,
|
||||
ListsWidgetReceiver::class.java,
|
||||
WeekWidgetReceiver::class.java,
|
||||
)
|
||||
|
||||
/** Redraws every placed home-screen widget. */
|
||||
internal suspend fun updateAllWidgets(context: Context) {
|
||||
TaskWidget().updateAll(context)
|
||||
TodayWidget().updateAll(context)
|
||||
UpNextWidget().updateAll(context)
|
||||
ListsWidget().updateAll(context)
|
||||
WeekWidget().updateAll(context)
|
||||
}
|
||||
|
||||
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</vector>
|
||||
@@ -1,6 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock of WidgetProgressRing for the widget-picker preview. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke android:width="7dp" android:color="@color/widget_preview_primary" />
|
||||
</shape>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
||||
widget is rendered by Glance. -->
|
||||
<GridLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="8dp"
|
||||
android:columnCount="2"
|
||||
android:rowCount="2"
|
||||
tools:ignore="HardcodedText">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_columnWeight="1"
|
||||
android:layout_rowWeight="1"
|
||||
android:layout_margin="4dp"
|
||||
android:background="@drawable/preview_stripe"
|
||||
android:backgroundTint="#B48A9C"
|
||||
android:padding="8dp"
|
||||
android:gravity="bottom"
|
||||
android:text="Home\n3"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_columnWeight="1"
|
||||
android:layout_rowWeight="1"
|
||||
android:layout_margin="4dp"
|
||||
android:background="@drawable/preview_stripe"
|
||||
android:backgroundTint="#7FA3C9"
|
||||
android:padding="8dp"
|
||||
android:gravity="bottom"
|
||||
android:text="Work\n5"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="13sp" />
|
||||
</GridLayout>
|
||||
@@ -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" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -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" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -91,7 +91,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" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -130,7 +130,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" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
||||
widget is rendered by Glance. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="10dp"
|
||||
tools:ignore="HardcodedText,UseAppTint">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/smart_today"
|
||||
android:textColor="@color/widget_preview_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="4dp">
|
||||
<ImageView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:layout_gravity="center"
|
||||
android:contentDescription="@null"
|
||||
android:background="@drawable/preview_ring" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="4/6"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
||||
widget is rendered by Glance. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="10dp"
|
||||
tools:ignore="HardcodedText,UseAppTint">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
android:layout_marginEnd="9dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/floret_ic_widget_check"
|
||||
android:tint="@color/widget_preview_variant" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Call the dentist"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="in 3 h"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
||||
widget is rendered by Glance. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="6dp"
|
||||
tools:ignore="HardcodedText">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="2dp"
|
||||
android:background="@color/widget_preview_primary_container"
|
||||
android:gravity="center"
|
||||
android:text="M\n17\n2"
|
||||
android:textColor="@color/widget_preview_on_primary_container"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="2dp"
|
||||
android:background="@color/widget_preview_surface"
|
||||
android:gravity="center"
|
||||
android:text="T\n18\n1"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="2dp"
|
||||
android:background="@color/widget_preview_surface"
|
||||
android:gravity="center"
|
||||
android:text="W\n19\n·"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="2dp"
|
||||
android:background="@color/widget_preview_surface"
|
||||
android:gravity="center"
|
||||
android:text="T\n20\n3"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
@@ -757,11 +757,68 @@
|
||||
<!-- Home-screen widget -->
|
||||
<string name="widget_tasks_label">Tasks</string>
|
||||
<string name="widget_tasks_description">Today\'s tasks, or any list, with a tap to tick them off</string>
|
||||
<string name="widget_empty">Nothing to do here</string>
|
||||
<string name="widget_empty">All caught up</string>
|
||||
<string name="widget_empty_hint">Nothing left to do, enjoy it</string>
|
||||
<string name="widget_unavailable">Open Agendula to finish setting up</string>
|
||||
<string name="widget_complete_task">Complete %1$s</string>
|
||||
<string name="widget_config_title">Show in widget</string>
|
||||
<string name="widget_config_smart_lists">Smart lists</string>
|
||||
<string name="widget_config_group_by_list">Group by list</string>
|
||||
<string name="widget_config_group_by_list_hint">Show each list\'s tasks under its own heading</string>
|
||||
<string name="widget_section_overdue">Overdue</string>
|
||||
<string name="widget_section_later">Later</string>
|
||||
<string name="widget_section_no_date">No date</string>
|
||||
<string name="widget_config_background">Background</string>
|
||||
<string name="widget_config_background_solid">Solid</string>
|
||||
<string name="widget_config_background_solid_hint">Fills the widget with the theme\'s surface colour</string>
|
||||
<string name="widget_config_background_tinted">Tinted</string>
|
||||
<string name="widget_config_background_tinted_hint">A softer, tonal fill</string>
|
||||
<string name="widget_config_background_transparent">Transparent</string>
|
||||
<string name="widget_config_background_transparent_hint">No fill — tasks stay readable in their own containers</string>
|
||||
<string name="widget_config_theme">Theme</string>
|
||||
<string name="widget_config_theme_follow_app">Follow app</string>
|
||||
<string name="widget_config_theme_light">Light</string>
|
||||
<string name="widget_config_theme_dark">Dark</string>
|
||||
<string name="widget_config_row_color">Row colour</string>
|
||||
<string name="widget_config_row_color_neutral">Neutral</string>
|
||||
<string name="widget_config_row_color_neutral_hint">Every row uses the same tonal fill</string>
|
||||
<string name="widget_config_row_color_by_list">By list</string>
|
||||
<string name="widget_config_row_color_by_list_hint">Each row is tinted with its list\'s own colour</string>
|
||||
<string name="widget_today_label">Today</string>
|
||||
<string name="widget_today_description">A progress ring for today\'s tasks</string>
|
||||
<string name="widget_today_all_done">All done</string>
|
||||
<string name="widget_today_nothing_due">Nothing due today</string>
|
||||
<plurals name="widget_today_progress_cd">
|
||||
<item quantity="one">%1$d of %2$d done</item>
|
||||
<item quantity="other">%1$d of %2$d done</item>
|
||||
</plurals>
|
||||
<string name="widget_up_next_label">Up next</string>
|
||||
<string name="widget_up_next_description">Your single nearest due task</string>
|
||||
<string name="widget_up_next_empty">Nothing due</string>
|
||||
<string name="widget_up_next_soon">Soon</string>
|
||||
<string name="widget_up_next_tomorrow">Tomorrow</string>
|
||||
<string name="widget_up_next_then">Then</string>
|
||||
<string name="widget_up_next_any_list">All lists</string>
|
||||
<plurals name="widget_up_next_in_hours">
|
||||
<item quantity="one">in %1$d h</item>
|
||||
<item quantity="other">in %1$d h</item>
|
||||
</plurals>
|
||||
<plurals name="widget_up_next_in_days">
|
||||
<item quantity="one">in %1$d d</item>
|
||||
<item quantity="other">in %1$d d</item>
|
||||
</plurals>
|
||||
<plurals name="widget_up_next_overdue_hours">
|
||||
<item quantity="one">%1$d h overdue</item>
|
||||
<item quantity="other">%1$d h overdue</item>
|
||||
</plurals>
|
||||
<plurals name="widget_up_next_overdue_days">
|
||||
<item quantity="one">%1$d d overdue</item>
|
||||
<item quantity="other">%1$d d overdue</item>
|
||||
</plurals>
|
||||
<string name="widget_lists_label">Lists</string>
|
||||
<string name="widget_lists_description">A shortcut tile per list, with its open count</string>
|
||||
<string name="widget_lists_empty">No lists to show</string>
|
||||
<string name="widget_lists_config_title">Lists to show</string>
|
||||
<string name="widget_week_label">Week</string>
|
||||
<string name="widget_week_description">A seven-day strip of task counts</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:minResizeWidth="57dp"
|
||||
android:minResizeHeight="57dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_lists_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewLayout="@layout/widget_preview_lists"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_today_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewLayout="@layout/widget_preview_today"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="56dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="1"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="56dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_up_next_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewLayout="@layout/widget_preview_up_next"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="56dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="1"
|
||||
android:minResizeWidth="250dp"
|
||||
android:minResizeHeight="56dp"
|
||||
android:resizeMode="vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_week_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewLayout="@layout/widget_preview_week"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -10,6 +10,9 @@ fun testTask(
|
||||
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
priority: Priority = Priority.NONE,
|
||||
due: Instant? = null,
|
||||
isAllDay: Boolean = false,
|
||||
completedAt: Instant? = null,
|
||||
parentId: Long? = null,
|
||||
): Task = Task(
|
||||
taskId = id,
|
||||
listId = listId,
|
||||
@@ -22,14 +25,14 @@ fun testTask(
|
||||
percentComplete = null,
|
||||
start = null,
|
||||
due = due,
|
||||
isAllDay = false,
|
||||
isAllDay = isAllDay,
|
||||
timeZone = null,
|
||||
completedAt = null,
|
||||
completedAt = completedAt,
|
||||
listColor = 0,
|
||||
taskColor = null,
|
||||
listName = null,
|
||||
accountName = null,
|
||||
parentId = null,
|
||||
parentId = parentId,
|
||||
isRecurring = false,
|
||||
occurrenceStart = null,
|
||||
distanceFromCurrent = 0,
|
||||
|
||||
@@ -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<WidgetSection.Header>().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<WidgetSection.Header>()).hasSize(1)
|
||||
assertThat(sections.filterIsInstance<WidgetSection.TaskItem>().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<WidgetSection.Header>().map { it.name }).containsExactly("Alpha", "Beta").inOrder()
|
||||
assertThat(sections.filterIsInstance<WidgetSection.TaskItem>().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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule floret-kit updated: db4be68dad...cb109c0e82
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user