feat(widget): scrap 'At a glance', add Today/Up next/Lists/Week
'At a glance' predated the Tasks widget's M3 Expressive redesign and only duplicated a subset of what Tasks already shows (today's tasks), so it's gone rather than patched up. In its place, four widgets that each do something Tasks structurally can't: - Today: a progress ring for done vs. still-open-today tasks - Up next: the single nearest due task, as a countdown - Lists: a shortcut tile per list, with its own open count - Week: a seven-day strip of per-day task counts Shared data lives in WidgetStats.kt (pure, unit-tested); the config activity is now kind-aware per widget provider, and the task-row building blocks (AgendaTaskRow, the row mapper) are shared rather than duplicated. Needs the floret-kit bump for WidgetProgressRing/WidgetTile /WidgetCheckButton.
This commit is contained in:
@@ -185,18 +185,56 @@
|
||||
android:resource="@xml/appwidget_info_tasks" />
|
||||
</receiver>
|
||||
|
||||
<!-- "At a glance" home-screen widget (Glance): the next task, or a quick-add
|
||||
button when too small to show one. -->
|
||||
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
|
||||
<receiver
|
||||
android:name=".widget.GlanceTaskWidgetReceiver"
|
||||
android:label="@string/widget_glance_label"
|
||||
android:name=".widget.TodayWidgetReceiver"
|
||||
android:label="@string/widget_today_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_glance" />
|
||||
android:resource="@xml/appwidget_info_today" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
|
||||
<receiver
|
||||
android:name=".widget.UpNextWidgetReceiver"
|
||||
android:label="@string/widget_up_next_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_up_next" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
|
||||
<receiver
|
||||
android:name=".widget.ListsWidgetReceiver"
|
||||
android:label="@string/widget_lists_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_lists" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
|
||||
<receiver
|
||||
android:name=".widget.WeekWidgetReceiver"
|
||||
android:label="@string/widget_week_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_week" />
|
||||
</receiver>
|
||||
|
||||
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* "At a glance" — a small widget that leads with the single thing worth seeing
|
||||
* without opening the app: the next open task today, or (too small to show one)
|
||||
* just a quick-add button. Always Today + overdue, since a tiny widget has no
|
||||
* room for a filter picker of its own — see [TaskWidgetConfigActivity] for
|
||||
* background/theme.
|
||||
*/
|
||||
class GlanceTaskWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, RegularSize))
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
|
||||
val initial = runCatching { context.taskWidgetData(FILTER, false).first() }
|
||||
.getOrDefault(TaskWidgetData.Unavailable)
|
||||
provideContent {
|
||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
||||
val data by remember { context.taskWidgetData(FILTER, false) }.collectAsState(initial = initial)
|
||||
val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
|
||||
FloretGlanceTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
dynamicColor = ready?.dynamicColor ?: true,
|
||||
mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM,
|
||||
) {
|
||||
GlanceTaskWidgetBody(data, background)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val FILTER = TaskFilter.Smart(SmartList.TODAY)
|
||||
val CompactSize = DpSize(56.dp, 56.dp)
|
||||
val RegularSize = DpSize(110.dp, 110.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceTaskWidgetBody(data: TaskWidgetData?, background: WidgetBackground) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? TaskWidgetData.Ready
|
||||
val compact = LocalSize.current.width < 90.dp
|
||||
WidgetSurface(background = background) {
|
||||
if (compact || ready == null) {
|
||||
Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
WidgetActionButton(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context)),
|
||||
style = WidgetActionStyle.FILLED,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(8.dp),
|
||||
) {
|
||||
GlanceSummary(ready)
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
val next = ready.rows.firstOrNull()
|
||||
if (next == null) {
|
||||
Text(
|
||||
text = context.getString(R.string.widget_glance_no_tasks),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
)
|
||||
} else {
|
||||
GlanceNextTaskRow(next, ready.use24Hour)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceSummary(ready: TaskWidgetData.Ready) {
|
||||
val context = LocalContext.current
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${ready.rows.size}",
|
||||
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 24.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Column(modifier = GlanceModifier.defaultWeight().padding(start = 6.dp)) {
|
||||
Text(
|
||||
text = context.getString(R.string.smart_today),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp),
|
||||
)
|
||||
if (ready.overdueCount > 0) {
|
||||
Text(
|
||||
text = context.resources.getQuantityString(
|
||||
R.plurals.widget_glance_overdue_count, ready.overdueCount, ready.overdueCount,
|
||||
),
|
||||
style = TextStyle(color = GlanceTheme.colors.error, fontSize = 11.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
WidgetActionButton(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceNextTaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||
Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(32.dp).clickable(
|
||||
actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(
|
||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
||||
CompleteTaskAction.OccurrenceKey to
|
||||
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
||||
),
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(GlanceR.drawable.floret_ic_widget_check),
|
||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = GlanceModifier.defaultWeight().padding(start = 4.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 2,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp),
|
||||
)
|
||||
// This widget is always Today + overdue (see the class doc), and
|
||||
// GlanceSummary already labels the group "Today" — so, like the main
|
||||
// Tasks widget's own rows, a same-day due only needs its bare time.
|
||||
dueText(context, row, use24Hour, suppressDayLabel = true)?.let { due ->
|
||||
Text(
|
||||
text = due,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Start,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.unit.ColorProvider as GlanceColorProvider
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.WidgetTile
|
||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* "Lists" home-screen widget: one shortcut tile per list, in the list's own
|
||||
* colour, showing its open count — navigation to a list rather than a
|
||||
* preview of its tasks, which is what [TaskWidget] already does.
|
||||
*/
|
||||
class ListsWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, WideSize))
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
|
||||
provideContent {
|
||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
||||
val listIds = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))
|
||||
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
|
||||
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
|
||||
FloretGlanceTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
|
||||
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
|
||||
) {
|
||||
ListsWidgetBody(data, background, listIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val CompactSize = DpSize(110.dp, 110.dp)
|
||||
val WideSize = DpSize(250.dp, 180.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listIds: Set<Long>?) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? WidgetStatsData.Ready
|
||||
val tiles = ready?.snapshot?.lists?.let { lists -> listIds?.let { ids -> lists.filter { it.id in ids } } ?: lists }
|
||||
val columns = if (LocalSize.current.width < 250.dp) 2 else 3
|
||||
WidgetSurface(background = background) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
|
||||
when {
|
||||
data == null -> Unit
|
||||
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
tiles.isNullOrEmpty() -> WidgetMessage(context.getString(R.string.widget_lists_empty))
|
||||
else -> {
|
||||
val rows = tiles.chunked(columns)
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(rows.size) { index ->
|
||||
Column {
|
||||
ListTileRow(rows[index], columns)
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListTileRow(rowTiles: List<ListTile>, columns: Int) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
rowTiles.forEachIndexed { index, tile ->
|
||||
if (index > 0) Spacer(GlanceModifier.width(8.dp))
|
||||
ListTileCell(tile, GlanceModifier.defaultWeight())
|
||||
}
|
||||
repeat(columns - rowTiles.size) {
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListTileCell(tile: ListTile, modifier: GlanceModifier) {
|
||||
val context = LocalContext.current
|
||||
val tint = ColorProvider(day = pastelize(tile.color, false), night = pastelize(tile.color, true))
|
||||
WidgetTile(
|
||||
color = tint,
|
||||
modifier = modifier.height(72.dp),
|
||||
onClick = actionStartActivity(MainActivity.listIntent(context, tile.id)),
|
||||
) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = tile.name,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
style = TextStyle(color = GlanceColorProvider(Color.White), fontSize = 13.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
if (tile.acceptsWrites) {
|
||||
WidgetActionButton(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context, tile.id)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
size = 20.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Text(
|
||||
text = "${tile.openCount}",
|
||||
style = TextStyle(
|
||||
color = GlanceColorProvider(Color.White),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -6,9 +6,9 @@ import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [GlanceTaskWidget]; also keeps the midnight rollover armed. */
|
||||
class GlanceTaskWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = GlanceTaskWidget()
|
||||
/** Host-facing receiver for [ListsWidget]; also keeps the midnight rollover armed. */
|
||||
class ListsWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = ListsWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
@@ -10,11 +10,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.ActionParameters
|
||||
@@ -25,12 +23,10 @@ import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
@@ -61,6 +57,7 @@ import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetCheckButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
@@ -131,16 +128,13 @@ class CompleteTaskAction : ActionCallback {
|
||||
runCatching {
|
||||
context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true)
|
||||
}
|
||||
// This callback is shared by both TaskWidget's and GlanceTaskWidget's
|
||||
// rows, and glanceId only tells us the tapped widget's instance, not
|
||||
// which of the two provider classes it belongs to — updating the wrong
|
||||
// one by id would push this class's RemoteViews into the other's
|
||||
// AppWidget slot. updateAll is class-scoped, so redrawing both is the
|
||||
// immediate, correct option; it also covers every other open instance
|
||||
// of either widget showing this task.
|
||||
val appContext = context.applicationContext
|
||||
TaskWidget().updateAll(appContext)
|
||||
GlanceTaskWidget().updateAll(appContext)
|
||||
// This callback is shared by every widget's rows, and glanceId only
|
||||
// tells us the tapped widget's instance, not which provider class it
|
||||
// belongs to — updating the wrong one by id would push this class's
|
||||
// RemoteViews into another widget's AppWidget slot. updateAll is
|
||||
// class-scoped, so redrawing every widget is the immediate, correct
|
||||
// option; it also covers every other open instance showing this task.
|
||||
updateAllWidgets(context.applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -296,7 +290,7 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
|
||||
* neutral tonal fill) reads too dark against them.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaTaskRow(
|
||||
internal fun AgendaTaskRow(
|
||||
row: WidgetTaskRow,
|
||||
use24Hour: Boolean,
|
||||
color: GlanceColorProvider,
|
||||
@@ -316,28 +310,17 @@ private fun AgendaTaskRow(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(28.dp)
|
||||
.cornerRadius(14.dp)
|
||||
.clickable(
|
||||
actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(
|
||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
||||
CompleteTaskAction.OccurrenceKey to
|
||||
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
||||
),
|
||||
),
|
||||
WidgetCheckButton(
|
||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
||||
action = actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(
|
||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
||||
CompleteTaskAction.OccurrenceKey to
|
||||
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(GlanceR.drawable.floret_ic_widget_check),
|
||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
||||
colorFilter = ColorFilter.tint(onColorVariant),
|
||||
modifier = GlanceModifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
),
|
||||
tint = onColorVariant,
|
||||
)
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight().clickable(open)) {
|
||||
Text(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -28,6 +29,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
@@ -58,10 +60,37 @@ import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Which widget provider a configure session is for — decides which sections [WidgetFilterPicker] shows. */
|
||||
internal enum class WidgetKind { TASKS, TODAY, UP_NEXT, LISTS, WEEK }
|
||||
|
||||
/** Reads [appWidgetId]'s provider class from the host, since that's the only thing naming which widget this is. */
|
||||
private fun widgetKindOf(context: Context, appWidgetId: Int): WidgetKind? {
|
||||
val provider = AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
||||
return when (provider.className) {
|
||||
TaskWidgetReceiver::class.java.name -> WidgetKind.TASKS
|
||||
TodayWidgetReceiver::class.java.name -> WidgetKind.TODAY
|
||||
UpNextWidgetReceiver::class.java.name -> WidgetKind.UP_NEXT
|
||||
ListsWidgetReceiver::class.java.name -> WidgetKind.LISTS
|
||||
WeekWidgetReceiver::class.java.name -> WidgetKind.WEEK
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun widgetFor(kind: WidgetKind): GlanceAppWidget = when (kind) {
|
||||
WidgetKind.TASKS -> TaskWidget()
|
||||
WidgetKind.TODAY -> TodayWidget()
|
||||
WidgetKind.UP_NEXT -> UpNextWidget()
|
||||
WidgetKind.LISTS -> ListsWidget()
|
||||
WidgetKind.WEEK -> WeekWidget()
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks what one widget instance shows: a smart list, or one or more real
|
||||
* lists (with an optional "group by list" sort). Runs when the widget is
|
||||
* placed (below API 31) and on "reconfigure" from the launcher.
|
||||
* Picks what one widget instance shows. [WidgetKind.TASKS] picks a smart
|
||||
* list or one or more real lists (with an optional "group by list" sort);
|
||||
* [WidgetKind.LISTS] picks which lists get a tile; every other kind only
|
||||
* offers background and theme, since they're always the smart-list scope.
|
||||
* Runs when the widget is placed (below API 31) and on "reconfigure" from
|
||||
* the launcher.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
@@ -79,6 +108,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val kind = widgetKindOf(this, appWidgetId) ?: WidgetKind.TASKS
|
||||
enableEdgeToEdge()
|
||||
val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId)
|
||||
val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow())
|
||||
@@ -92,6 +122,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
|
||||
var themeOverride by remember { mutableStateOf<WidgetThemeMode?>(null) }
|
||||
var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
|
||||
var selectedListIds by remember { mutableStateOf<Set<Long>?>(null) }
|
||||
LaunchedEffect(glanceId) {
|
||||
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
||||
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
|
||||
@@ -99,6 +130,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY])
|
||||
themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY])
|
||||
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
|
||||
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
|
||||
}
|
||||
val dark = when (settings.themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
@@ -108,6 +140,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
WidgetFilterPicker(
|
||||
kind = kind,
|
||||
lists = lists.filter { it.isVisible },
|
||||
dark = dark,
|
||||
selected = current ?: DEFAULT_WIDGET_FILTER,
|
||||
@@ -119,17 +152,28 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
onThemeOverrideChange = { themeOverride = it },
|
||||
rowColor = rowColor,
|
||||
onRowColorChange = { rowColor = it },
|
||||
selectedListIds = selectedListIds,
|
||||
onSelectedListIdsChange = { selectedListIds = it },
|
||||
onSave = { filter ->
|
||||
lifecycleScope.launch {
|
||||
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
|
||||
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
|
||||
it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
|
||||
when (kind) {
|
||||
WidgetKind.TASKS -> {
|
||||
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
|
||||
it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
|
||||
it[WIDGET_ROW_COLOR_KEY] = rowColor.name
|
||||
}
|
||||
WidgetKind.LISTS -> {
|
||||
selectedListIds?.let { ids -> it[WIDGET_LIST_IDS_KEY] = ids.widgetListIdsStorageValue() }
|
||||
?: it.remove(WIDGET_LIST_IDS_KEY)
|
||||
}
|
||||
WidgetKind.TODAY, WidgetKind.UP_NEXT, WidgetKind.WEEK -> Unit
|
||||
}
|
||||
it[WIDGET_BACKGROUND_KEY] = background.name
|
||||
themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name }
|
||||
?: it.remove(WIDGET_THEME_KEY)
|
||||
it[WIDGET_ROW_COLOR_KEY] = rowColor.name
|
||||
}
|
||||
TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
|
||||
widgetFor(kind).update(this@TaskWidgetConfigActivity, glanceId)
|
||||
setResult(RESULT_OK, resultIntent(appWidgetId))
|
||||
finish()
|
||||
}
|
||||
@@ -146,6 +190,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
private fun WidgetFilterPicker(
|
||||
kind: WidgetKind,
|
||||
lists: List<TaskList>,
|
||||
selected: TaskFilter,
|
||||
dark: Boolean,
|
||||
@@ -157,6 +202,8 @@ private fun WidgetFilterPicker(
|
||||
onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
|
||||
rowColor: WidgetRowColor,
|
||||
onRowColorChange: (WidgetRowColor) -> Unit,
|
||||
selectedListIds: Set<Long>?,
|
||||
onSelectedListIdsChange: (Set<Long>?) -> Unit,
|
||||
onSave: (TaskFilter) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -177,10 +224,10 @@ private fun WidgetFilterPicker(
|
||||
},
|
||||
)
|
||||
}
|
||||
val canSave = selectedSmart != null || selectedLists.isNotEmpty()
|
||||
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.widget_config_title),
|
||||
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
|
||||
onDismiss = onDismiss,
|
||||
actions = {
|
||||
TextButton(
|
||||
@@ -197,21 +244,51 @@ private fun WidgetFilterPicker(
|
||||
) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
) {
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
|
||||
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
|
||||
val isSelected = smart == selectedSmart
|
||||
GroupedRow(
|
||||
title = stringResource(smartListLabel(smart)),
|
||||
position = positionOf(index, WIDGET_SMART_LISTS.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { selectedSmart = smart; selectedLists = emptySet() },
|
||||
)
|
||||
}
|
||||
if (lists.isNotEmpty()) {
|
||||
if (kind == WidgetKind.TASKS) {
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
|
||||
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
|
||||
val isSelected = smart == selectedSmart
|
||||
GroupedRow(
|
||||
title = stringResource(smartListLabel(smart)),
|
||||
position = positionOf(index, WIDGET_SMART_LISTS.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = { selectedSmart = smart; selectedLists = emptySet() },
|
||||
)
|
||||
}
|
||||
if (lists.isNotEmpty()) {
|
||||
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||
lists.forEachIndexed { index, list ->
|
||||
val isSelected = list.id in selectedLists
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
position = positionOf(index, lists.size),
|
||||
selected = isSelected,
|
||||
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = {
|
||||
selectedSmart = null
|
||||
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
|
||||
},
|
||||
)
|
||||
}
|
||||
if (selectedLists.size > 1) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_group_by_list),
|
||||
summary = stringResource(R.string.widget_config_group_by_list_hint),
|
||||
position = Position.Alone,
|
||||
trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
|
||||
onClick = { onGroupByListChange(!groupByList) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
} else if (kind == WidgetKind.LISTS && lists.isNotEmpty()) {
|
||||
GroupedSectionHeader(stringResource(R.string.lists_header))
|
||||
lists.forEachIndexed { index, list ->
|
||||
val isSelected = list.id in selectedLists
|
||||
// `null` means "every list" (the default) until the user narrows it down.
|
||||
val isSelected = selectedListIds?.let { list.id in it } ?: true
|
||||
GroupedRow(
|
||||
title = list.name,
|
||||
position = positionOf(index, lists.size),
|
||||
@@ -219,41 +296,40 @@ private fun WidgetFilterPicker(
|
||||
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
|
||||
trailing = if (isSelected) ({ SelectedCheck() }) else null,
|
||||
onClick = {
|
||||
selectedSmart = null
|
||||
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
|
||||
val current = selectedListIds ?: lists.map { it.id }.toSet()
|
||||
onSelectedListIdsChange(if (isSelected) current - list.id else current + list.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (selectedLists.size > 1) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_group_by_list),
|
||||
summary = stringResource(R.string.widget_config_group_by_list_hint),
|
||||
position = Position.Alone,
|
||||
trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
|
||||
onClick = { onGroupByListChange(!groupByList) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_background),
|
||||
summary = stringResource(backgroundLabel(background)),
|
||||
position = Position.Top,
|
||||
onClick = { showBackgroundPicker = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_theme),
|
||||
summary = stringResource(themeOverrideLabel(themeOverride)),
|
||||
position = Position.Middle,
|
||||
onClick = { showThemePicker = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_row_color),
|
||||
summary = stringResource(rowColorLabel(rowColor)),
|
||||
position = Position.Bottom,
|
||||
onClick = { showRowColorPicker = true },
|
||||
)
|
||||
if (kind == WidgetKind.TASKS) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_theme),
|
||||
summary = stringResource(themeOverrideLabel(themeOverride)),
|
||||
position = Position.Middle,
|
||||
onClick = { showThemePicker = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_row_color),
|
||||
summary = stringResource(rowColorLabel(rowColor)),
|
||||
position = Position.Bottom,
|
||||
onClick = { showRowColorPicker = true },
|
||||
)
|
||||
} else {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_theme),
|
||||
summary = stringResource(themeOverrideLabel(themeOverride)),
|
||||
position = Position.Bottom,
|
||||
onClick = { showThemePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showBackgroundPicker) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
@@ -52,8 +51,7 @@ class TaskWidgetUpdater @Inject constructor(
|
||||
private suspend fun redraw() {
|
||||
runCatching {
|
||||
if (!WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) return@runCatching
|
||||
TaskWidget().updateAll(context)
|
||||
GlanceTaskWidget().updateAll(context)
|
||||
updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.WidgetProgressRing
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* "Today" home-screen widget: how far through today you are, as a ring —
|
||||
* done ÷ (done + still open today/overdue) — with the next open task shown
|
||||
* below it once the widget is tall enough to carry one. Complements
|
||||
* [TaskWidget] rather than repeating it: a glance stat, not another list.
|
||||
*/
|
||||
class TodayWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, TallSize))
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
|
||||
provideContent {
|
||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
||||
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
|
||||
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
|
||||
FloretGlanceTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
|
||||
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
|
||||
) {
|
||||
TodayWidgetBody(data, background)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val CompactSize = DpSize(110.dp, 110.dp)
|
||||
val TallSize = DpSize(110.dp, 180.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? WidgetStatsData.Ready
|
||||
val tall = LocalSize.current.height >= 150.dp
|
||||
WidgetSurface(background = background) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
|
||||
WidgetHeader(
|
||||
title = context.getString(R.string.smart_today),
|
||||
titleAction = actionStartActivity(MainActivity.todayIntent(context)),
|
||||
) {
|
||||
WidgetActionButton(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context)),
|
||||
style = WidgetActionStyle.FILLED,
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when {
|
||||
data == null -> Unit
|
||||
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
|
||||
else -> {
|
||||
val ringAreaModifier = GlanceModifier.fillMaxWidth()
|
||||
.let { if (tall) it.defaultWeight() else it }
|
||||
.clickable(actionStartActivity(MainActivity.todayIntent(context)))
|
||||
Box(modifier = ringAreaModifier, contentAlignment = Alignment.Center) {
|
||||
TodayRing(ready)
|
||||
}
|
||||
if (tall) {
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
val next = ready.snapshot.todayNext
|
||||
if (next != null) {
|
||||
AgendaTaskRow(
|
||||
row = next,
|
||||
use24Hour = ready.snapshot.use24Hour,
|
||||
color = GlanceTheme.colors.surfaceVariant,
|
||||
colored = false,
|
||||
suppressDayLabel = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TodayRing(ready: WidgetStatsData.Ready) {
|
||||
val context = LocalContext.current
|
||||
val snapshot = ready.snapshot
|
||||
val total = snapshot.todayDone + snapshot.todayRemaining
|
||||
val allDone = total > 0 && snapshot.todayRemaining == 0
|
||||
val progress = if (total == 0) 0f else snapshot.todayDone.toFloat() / total
|
||||
val indicator = if (allDone) GlanceTheme.colors.tertiary else GlanceTheme.colors.primary
|
||||
WidgetProgressRing(
|
||||
progress = if (total == 0) 0f else progress,
|
||||
size = 72.dp,
|
||||
trackColor = GlanceTheme.colors.surfaceVariant,
|
||||
indicatorColor = indicator,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (total == 0) {
|
||||
Text(
|
||||
text = context.getString(R.string.widget_today_nothing_due),
|
||||
maxLines = 2,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
} else if (allDone) {
|
||||
Text(
|
||||
text = context.getString(R.string.widget_today_all_done),
|
||||
maxLines = 2,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.tertiary,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "${snapshot.todayDone}/$total",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [TodayWidget]; also keeps the midnight rollover armed. */
|
||||
class TodayWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetCheckButton
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.time.formatTime
|
||||
import de.jeanlucmakiola.floret.glance.R as GlanceR
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* "Up next" home-screen widget: the single open task with the nearest due
|
||||
* moment, big, with a check to complete it in place — a countdown rather
|
||||
* than a list, for someone who wants to see one thing at a time.
|
||||
*/
|
||||
class UpNextWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, WideSize))
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
|
||||
provideContent {
|
||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
||||
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
|
||||
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
|
||||
FloretGlanceTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
|
||||
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
|
||||
) {
|
||||
UpNextWidgetBody(data, background)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val CompactSize = DpSize(110.dp, 56.dp)
|
||||
val WideSize = DpSize(250.dp, 56.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpNextWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? WidgetStatsData.Ready
|
||||
val row = ready?.snapshot?.nextDue
|
||||
WidgetSurface(background = background) {
|
||||
when {
|
||||
data == null -> Unit
|
||||
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
|
||||
row == null -> UpNextEmpty(context)
|
||||
else -> UpNextRow(row, ready.snapshot.use24Hour)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpNextEmpty(context: Context) {
|
||||
Row(modifier = GlanceModifier.fillMaxSize().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
WidgetMessage(context.getString(R.string.widget_up_next_empty), GlanceModifier.defaultWeight())
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
WidgetActionButton(
|
||||
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
|
||||
contentDescription = context.getString(R.string.new_task),
|
||||
action = actionStartActivity(MainActivity.newTaskIntent(context)),
|
||||
style = WidgetActionStyle.FILLED,
|
||||
size = 36.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpNextRow(row: WidgetTaskRow, use24Hour: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||
val now = Clock.System.now()
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(8.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
|
||||
),
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WidgetCheckButton(
|
||||
contentDescription = context.getString(R.string.widget_complete_task, title),
|
||||
action = actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(
|
||||
CompleteTaskAction.TaskIdKey to row.taskId,
|
||||
CompleteTaskAction.OccurrenceKey to
|
||||
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
|
||||
),
|
||||
),
|
||||
tint = GlanceTheme.colors.onSurfaceVariant,
|
||||
)
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Text(
|
||||
text = upNextLabel(context, row, use24Hour, now),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero line under [row]'s title: a bare time when due today, "Tomorrow",
|
||||
* or a coarse relative count otherwise — this widget only redraws on data
|
||||
* changes, at midnight, and on the half-hourly `updatePeriodMillis`, so a
|
||||
* minute-level countdown would just look wrong between redraws.
|
||||
*/
|
||||
internal fun upNextLabel(context: Context, row: WidgetTaskRow, use24Hour: Boolean, now: Instant): String {
|
||||
val due = row.due ?: return ""
|
||||
if (row.dueKind == DueKind.TODAY && !row.isOverdue) return due.formatTime(use24Hour)
|
||||
if (row.isOverdue) {
|
||||
val hours = (now - due).inWholeHours
|
||||
return when {
|
||||
hours < 1 -> context.getString(R.string.widget_up_next_soon)
|
||||
hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_overdue_hours, hours.toInt(), hours.toInt())
|
||||
else -> {
|
||||
val days = hours / HOURS_PER_DAY
|
||||
context.resources.getQuantityString(R.plurals.widget_up_next_overdue_days, days.toInt(), days.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (row.dueKind == DueKind.TOMORROW) return context.getString(R.string.widget_up_next_tomorrow)
|
||||
val hours = (due - now).inWholeHours
|
||||
return when {
|
||||
hours < 1 -> context.getString(R.string.widget_up_next_soon)
|
||||
hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_in_hours, hours.toInt(), hours.toInt())
|
||||
else -> {
|
||||
val days = hours / HOURS_PER_DAY
|
||||
context.resources.getQuantityString(R.plurals.widget_up_next_in_days, days.toInt(), days.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val HOURS_PER_DAY = 24L
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [UpNextWidget]; also keeps the midnight rollover armed. */
|
||||
class UpNextWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = UpNextWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.WidgetTile
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.LocalDate
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* "Week" home-screen widget: a seven-day strip, one tile per day, each with
|
||||
* its open-task count — the one shape none of the app's other widgets or
|
||||
* screens offer (they're all "one list" or "one task" shaped).
|
||||
*/
|
||||
class WeekWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, TallSize))
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
|
||||
provideContent {
|
||||
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
|
||||
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
|
||||
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
|
||||
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
|
||||
FloretGlanceTheme(
|
||||
lightScheme = AgendulaLightFallback,
|
||||
darkScheme = AgendulaDarkFallback,
|
||||
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
|
||||
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
|
||||
) {
|
||||
WeekWidgetBody(data, background)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val CompactSize = DpSize(250.dp, 56.dp)
|
||||
val TallSize = DpSize(250.dp, 140.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
|
||||
val context = LocalContext.current
|
||||
val ready = data as? WidgetStatsData.Ready
|
||||
val tall = LocalSize.current.height >= 110.dp
|
||||
WidgetSurface(background = background) {
|
||||
when {
|
||||
data == null -> Unit
|
||||
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
|
||||
else -> {
|
||||
Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
|
||||
ready.snapshot.week.forEachIndexed { index, day ->
|
||||
if (index > 0) Spacer(GlanceModifier.width(4.dp))
|
||||
WeekDayCell(day, tall, GlanceModifier.defaultWeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekDayCell(day: WeekDay, tall: Boolean, modifier: GlanceModifier) {
|
||||
val context = LocalContext.current
|
||||
val today = isToday(day.date)
|
||||
val background = if (today) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.surfaceVariant
|
||||
val onBackground = if (today) GlanceTheme.colors.onPrimaryContainer else GlanceTheme.colors.onSurfaceVariant
|
||||
val open = if (today) {
|
||||
actionStartActivity(MainActivity.todayIntent(context))
|
||||
} else {
|
||||
actionStartActivity(MainActivity.smartListIntent(context, SmartList.UPCOMING))
|
||||
}
|
||||
WidgetTile(color = background, modifier = modifier, onClick = open) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val locale = context.resources.configuration.locales[0] ?: Locale.ROOT
|
||||
Text(
|
||||
text = day.date.dayOfWeek.getDisplayName(JavaTextStyle.NARROW, locale),
|
||||
style = TextStyle(color = onBackground, fontSize = 11.sp, textAlign = TextAlign.Center),
|
||||
)
|
||||
Text(
|
||||
text = "${day.date.dayOfMonth}",
|
||||
style = TextStyle(color = onBackground, fontSize = 13.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
|
||||
)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
if (day.count > 0) {
|
||||
Text(
|
||||
text = "${day.count}",
|
||||
style = TextStyle(
|
||||
color = if (day.overdueCount > 0) GlanceTheme.colors.error else onBackground,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (tall) {
|
||||
day.first?.let { row ->
|
||||
Text(
|
||||
text = row.title.ifBlank { "" },
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = onBackground, fontSize = 10.sp, textAlign = TextAlign.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isToday(date: LocalDate): Boolean = date == java.time.LocalDate.now()
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Host-facing receiver for [WeekWidget]; also keeps the midnight rollover armed. */
|
||||
class WeekWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = WeekWidget()
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,15 @@ internal val WIDGET_THEME_KEY = stringPreferencesKey("widget_theme")
|
||||
/** Per-instance Glance state key holding the widget's [WidgetRowColor] ([name]); unset = [WidgetRowColor.NEUTRAL]. */
|
||||
internal val WIDGET_ROW_COLOR_KEY = stringPreferencesKey("widget_row_color")
|
||||
|
||||
/** Per-instance Glance state key holding the Lists widget's chosen list ids, comma-joined; unset = every visible list. */
|
||||
internal val WIDGET_LIST_IDS_KEY = stringPreferencesKey("widget_list_ids")
|
||||
|
||||
/** `null` means "every visible list" — the Lists widget's default. */
|
||||
internal fun parseWidgetListIds(raw: String?): Set<Long>? =
|
||||
raw?.split(",")?.mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }?.toSet()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
internal fun Set<Long>.widgetListIdsStorageValue(): String = joinToString(",")
|
||||
|
||||
internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY)
|
||||
|
||||
/** How a row's tonal container is tinted: the neutral surface role, or the task's own list colour. */
|
||||
@@ -52,7 +61,11 @@ internal fun parseWidgetThemeOverride(raw: String?): WidgetThemeMode? =
|
||||
raw?.let { runCatching { WidgetThemeMode.valueOf(it) }.getOrNull() }
|
||||
|
||||
internal fun TaskWidgetData.Ready.effectiveThemeMode(override: WidgetThemeMode?): WidgetThemeMode =
|
||||
override ?: when (themeMode) {
|
||||
effectiveThemeMode(themeMode, override)
|
||||
|
||||
/** [override], or [appThemeMode] translated to its Glance counterpart — shared by every widget's data type. */
|
||||
internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMode?): WidgetThemeMode =
|
||||
override ?: when (appThemeMode) {
|
||||
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
||||
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
||||
ThemeMode.DARK -> WidgetThemeMode.DARK
|
||||
@@ -219,23 +232,22 @@ internal fun widgetRows(
|
||||
it.sortedWith(compareBy({ task -> listNames[task.listId].orEmpty() }, { task -> task.listId }))
|
||||
} else it
|
||||
}
|
||||
return sorted
|
||||
.take(MAX_WIDGET_ROWS)
|
||||
.map { task ->
|
||||
WidgetTaskRow(
|
||||
taskId = task.taskId,
|
||||
listId = task.listId,
|
||||
occurrenceStart = task.occurrenceStart,
|
||||
title = task.title,
|
||||
due = task.due,
|
||||
isAllDay = task.isAllDay,
|
||||
dueKind = task.due?.let { dueKind(it, task.isAllDay, today, zone) },
|
||||
isOverdue = task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } ?: false,
|
||||
color = task.effectiveColor,
|
||||
)
|
||||
}
|
||||
return sorted.take(MAX_WIDGET_ROWS).map { it.toWidgetRow(now, today, zone) }
|
||||
}
|
||||
|
||||
/** This task as a [WidgetTaskRow], resolving its due bucket and overdue state against [today]. */
|
||||
internal fun Task.toWidgetRow(now: Instant, today: LocalDate, zone: ZoneId): WidgetTaskRow = WidgetTaskRow(
|
||||
taskId = taskId,
|
||||
listId = listId,
|
||||
occurrenceStart = occurrenceStart,
|
||||
title = title,
|
||||
due = due,
|
||||
isAllDay = isAllDay,
|
||||
dueKind = due?.let { dueKind(it, isAllDay, today, zone) },
|
||||
isOverdue = due?.let { isOverdue(it, isAllDay, now, today, zone) } ?: false,
|
||||
color = effectiveColor,
|
||||
)
|
||||
|
||||
/**
|
||||
* Live widget data for [filter]. Re-emits on every store change, since the
|
||||
* repository's flows observe the store; a store that can't be read (permission
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateFormat
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||
import de.jeanlucmakiola.agendula.domain.comparator
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** One day of the Week widget's strip. [first] is that day's earliest task, in sort order. */
|
||||
internal data class WeekDay(val date: LocalDate, val count: Int, val overdueCount: Int, val first: WidgetTaskRow?)
|
||||
|
||||
/** One tile of the Lists widget. */
|
||||
internal data class ListTile(val id: Long, val name: String, val color: Int, val openCount: Int, val acceptsWrites: Boolean)
|
||||
|
||||
internal data class WidgetStatsSnapshot(
|
||||
val todayDone: Int,
|
||||
val todayRemaining: Int,
|
||||
val todayNext: WidgetTaskRow?,
|
||||
val nextDue: WidgetTaskRow?,
|
||||
val week: List<WeekDay>,
|
||||
val lists: List<ListTile>,
|
||||
val dynamicColor: Boolean,
|
||||
val themeMode: ThemeMode,
|
||||
val use24Hour: Boolean,
|
||||
)
|
||||
|
||||
internal sealed interface WidgetStatsData {
|
||||
/** No readable store yet, mirroring [TaskWidgetData.Unavailable]. */
|
||||
data object Unavailable : WidgetStatsData
|
||||
data class Ready(val snapshot: WidgetStatsSnapshot) : WidgetStatsData
|
||||
}
|
||||
|
||||
/**
|
||||
* How far through today a set of [open] and [completed] tasks says you are:
|
||||
* tasks done today (by [Task.completedAt]'s local date), tasks still open that
|
||||
* are overdue or due today (in [comparator] order), and the first of those.
|
||||
*/
|
||||
internal fun todayProgress(
|
||||
open: List<Task>,
|
||||
completed: List<Task>,
|
||||
comparator: Comparator<Task>,
|
||||
now: Instant,
|
||||
today: LocalDate,
|
||||
zone: ZoneId,
|
||||
): Triple<Int, Int, WidgetTaskRow?> {
|
||||
val done = completed.count { task ->
|
||||
val at = task.completedAt ?: return@count false
|
||||
at.toLocalDate(zone) == today
|
||||
}
|
||||
val remaining = open.filter { task ->
|
||||
val due = task.due ?: return@filter false
|
||||
isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY
|
||||
}.sortedWith(comparator)
|
||||
return Triple(done, remaining.size, remaining.firstOrNull()?.toWidgetRow(now, today, zone))
|
||||
}
|
||||
|
||||
/** The open task with the earliest due moment, overdue included — `null` when nothing is dated. */
|
||||
internal fun nextDue(open: List<Task>, now: Instant, today: LocalDate, zone: ZoneId): WidgetTaskRow? =
|
||||
open.filter { it.due != null }.minByOrNull { it.due!! }?.toWidgetRow(now, today, zone)
|
||||
|
||||
/**
|
||||
* Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue
|
||||
* task regardless of its own due day, since that's where the app's own
|
||||
* Overdue/Today smart lists put it; every other day matches its own due date.
|
||||
*/
|
||||
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> =
|
||||
(0..6L).map { offset ->
|
||||
val date = today.plusDays(offset)
|
||||
val dayTasks = open.filter { task ->
|
||||
val due = task.due ?: return@filter false
|
||||
if (offset == 0L) {
|
||||
isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date
|
||||
} else {
|
||||
due.calendarDate(task.isAllDay, zone) == date
|
||||
}
|
||||
}
|
||||
val overdueCount = if (offset == 0L) {
|
||||
dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) }
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone)
|
||||
WeekDay(date, dayTasks.size, overdueCount, first)
|
||||
}
|
||||
|
||||
/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */
|
||||
internal fun listTiles(open: List<Task>, lists: List<TaskList>): List<ListTile> {
|
||||
val present = open.mapTo(HashSet()) { it.taskId }
|
||||
val counted = open.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
|
||||
val counts = counted.groupingBy { it.listId }.eachCount()
|
||||
return lists.filter { it.isVisible }.map { list ->
|
||||
ListTile(list.id, list.name, list.color, counts[list.id] ?: 0, list.acceptsWrites)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Instant.toLocalDate(zone: ZoneId): LocalDate =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||
|
||||
/**
|
||||
* Live stats for the Today, Up next, Lists and Week widgets — the ALL and
|
||||
* COMPLETED smart lists, filtered the same way the app's own smart lists are
|
||||
* ([de.jeanlucmakiola.agendula.data.prefs.Settings.visibleIn]).
|
||||
*/
|
||||
internal fun Context.widgetStats(): Flow<WidgetStatsData> {
|
||||
val ep = widgetEntryPoint()
|
||||
val repository = ep.tasksRepository()
|
||||
return combine(
|
||||
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||
repository.taskLists(),
|
||||
ep.settingsPrefs().settings,
|
||||
) { openRaw, completedRaw, lists, settings ->
|
||||
val open = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), openRaw)
|
||||
val completed = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), completedRaw)
|
||||
val now = Clock.System.now()
|
||||
val zone = ZoneId.systemDefault()
|
||||
val today = now.toLocalDate(zone)
|
||||
val comparator = settings.sortOrder.comparator()
|
||||
val (done, remaining, next) = todayProgress(open, completed, comparator, now, today, zone)
|
||||
val data: WidgetStatsData = WidgetStatsData.Ready(
|
||||
WidgetStatsSnapshot(
|
||||
todayDone = done,
|
||||
todayRemaining = remaining,
|
||||
todayNext = next,
|
||||
nextDue = nextDue(open, now, today, zone),
|
||||
week = weekDays(open, comparator, now, today, zone),
|
||||
lists = listTiles(open, lists),
|
||||
dynamicColor = settings.dynamicColor,
|
||||
themeMode = settings.themeMode,
|
||||
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
|
||||
),
|
||||
)
|
||||
data
|
||||
}.catch { emit(WidgetStatsData.Unavailable) }
|
||||
}
|
||||
@@ -7,9 +7,23 @@ import androidx.glance.appwidget.updateAll
|
||||
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
|
||||
/** Both home-screen widgets' receiver classes — the set [WidgetRollover] checks for placed instances. */
|
||||
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> =
|
||||
listOf(TaskWidgetReceiver::class.java, GlanceTaskWidgetReceiver::class.java)
|
||||
/** Every home-screen widget's receiver class — the set [WidgetRollover] checks for placed instances. */
|
||||
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = listOf(
|
||||
TaskWidgetReceiver::class.java,
|
||||
TodayWidgetReceiver::class.java,
|
||||
UpNextWidgetReceiver::class.java,
|
||||
ListsWidgetReceiver::class.java,
|
||||
WeekWidgetReceiver::class.java,
|
||||
)
|
||||
|
||||
/** Redraws every placed home-screen widget. */
|
||||
internal suspend fun updateAllWidgets(context: Context) {
|
||||
TaskWidget().updateAll(context)
|
||||
TodayWidget().updateAll(context)
|
||||
UpNextWidget().updateAll(context)
|
||||
ListsWidget().updateAll(context)
|
||||
WeekWidget().updateAll(context)
|
||||
}
|
||||
|
||||
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER"
|
||||
|
||||
@@ -23,7 +37,7 @@ internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = Pend
|
||||
)
|
||||
|
||||
/**
|
||||
* Redraws both widgets when their day or language goes stale, and keeps the
|
||||
* Redraws every widget when its day or language goes stale, and keeps the
|
||||
* midnight rollover armed — the app-specific glue around
|
||||
* [FloretWidgetUpdateReceiver]. Task data changes don't come through here — see
|
||||
* [TaskWidgetUpdater].
|
||||
@@ -35,7 +49,6 @@ class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
||||
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
|
||||
|
||||
override suspend fun redraw(context: Context) {
|
||||
TaskWidget().updateAll(context)
|
||||
GlanceTaskWidget().updateAll(context)
|
||||
updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock of WidgetProgressRing for the widget-picker preview. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke android:width="7dp" android:color="@color/widget_preview_primary" />
|
||||
</shape>
|
||||
@@ -1,55 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
||||
widget is rendered by Glance. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="10dp"
|
||||
tools:ignore="HardcodedText,UseAppTint">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="3"
|
||||
android:textColor="@color/widget_preview_primary"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="6dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/smart_today"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<ImageView
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:padding="9dp"
|
||||
android:contentDescription="@null"
|
||||
android:background="@drawable/preview_add_bg"
|
||||
android:src="@drawable/floret_ic_widget_add"
|
||||
android:tint="@color/widget_preview_on_primary_container" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="Water the plants"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
||||
widget is rendered by Glance. -->
|
||||
<GridLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="8dp"
|
||||
android:columnCount="2"
|
||||
android:rowCount="2"
|
||||
tools:ignore="HardcodedText">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_columnWeight="1"
|
||||
android:layout_rowWeight="1"
|
||||
android:layout_margin="4dp"
|
||||
android:background="@drawable/preview_stripe"
|
||||
android:backgroundTint="#B48A9C"
|
||||
android:padding="8dp"
|
||||
android:gravity="bottom"
|
||||
android:text="Home\n3"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_columnWeight="1"
|
||||
android:layout_rowWeight="1"
|
||||
android:layout_margin="4dp"
|
||||
android:background="@drawable/preview_stripe"
|
||||
android:backgroundTint="#7FA3C9"
|
||||
android:padding="8dp"
|
||||
android:gravity="bottom"
|
||||
android:text="Work\n5"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="13sp" />
|
||||
</GridLayout>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
||||
widget is rendered by Glance. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="10dp"
|
||||
tools:ignore="HardcodedText,UseAppTint">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/smart_today"
|
||||
android:textColor="@color/widget_preview_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="4dp">
|
||||
<ImageView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:layout_gravity="center"
|
||||
android:contentDescription="@null"
|
||||
android:background="@drawable/preview_ring" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="4/6"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
||||
widget is rendered by Glance. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="10dp"
|
||||
tools:ignore="HardcodedText,UseAppTint">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
android:layout_marginEnd="9dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/floret_ic_widget_check"
|
||||
android:tint="@color/widget_preview_variant" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Call the dentist"
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="in 3 h"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout); the live
|
||||
widget is rendered by Glance. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:padding="6dp"
|
||||
tools:ignore="HardcodedText">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="2dp"
|
||||
android:background="@color/widget_preview_primary_container"
|
||||
android:gravity="center"
|
||||
android:text="M\n17\n2"
|
||||
android:textColor="@color/widget_preview_on_primary_container"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="2dp"
|
||||
android:background="@color/widget_preview_surface"
|
||||
android:gravity="center"
|
||||
android:text="T\n18\n1"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="2dp"
|
||||
android:background="@color/widget_preview_surface"
|
||||
android:gravity="center"
|
||||
android:text="W\n19\n·"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="2dp"
|
||||
android:background="@color/widget_preview_surface"
|
||||
android:gravity="center"
|
||||
android:text="T\n20\n3"
|
||||
android:textColor="@color/widget_preview_variant"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
@@ -783,11 +783,39 @@
|
||||
<string name="widget_config_row_color_neutral_hint">Every row uses the same tonal fill</string>
|
||||
<string name="widget_config_row_color_by_list">By list</string>
|
||||
<string name="widget_config_row_color_by_list_hint">Each row is tinted with its list\'s own colour</string>
|
||||
<string name="widget_glance_label">At a glance</string>
|
||||
<string name="widget_glance_description">Your next task, or a quick way to add one</string>
|
||||
<string name="widget_glance_no_tasks">Nothing due today</string>
|
||||
<plurals name="widget_glance_overdue_count">
|
||||
<item quantity="one">%1$d overdue</item>
|
||||
<item quantity="other">%1$d overdue</item>
|
||||
<string name="widget_today_label">Today</string>
|
||||
<string name="widget_today_description">A progress ring for today\'s tasks</string>
|
||||
<string name="widget_today_all_done">All done</string>
|
||||
<string name="widget_today_nothing_due">Nothing due today</string>
|
||||
<plurals name="widget_today_progress_cd">
|
||||
<item quantity="one">%1$d of %2$d done</item>
|
||||
<item quantity="other">%1$d of %2$d done</item>
|
||||
</plurals>
|
||||
<string name="widget_up_next_label">Up next</string>
|
||||
<string name="widget_up_next_description">Your single nearest due task</string>
|
||||
<string name="widget_up_next_empty">Nothing due</string>
|
||||
<string name="widget_up_next_soon">Soon</string>
|
||||
<string name="widget_up_next_tomorrow">Tomorrow</string>
|
||||
<plurals name="widget_up_next_in_hours">
|
||||
<item quantity="one">in %1$d h</item>
|
||||
<item quantity="other">in %1$d h</item>
|
||||
</plurals>
|
||||
<plurals name="widget_up_next_in_days">
|
||||
<item quantity="one">in %1$d d</item>
|
||||
<item quantity="other">in %1$d d</item>
|
||||
</plurals>
|
||||
<plurals name="widget_up_next_overdue_hours">
|
||||
<item quantity="one">%1$d h overdue</item>
|
||||
<item quantity="other">%1$d h overdue</item>
|
||||
</plurals>
|
||||
<plurals name="widget_up_next_overdue_days">
|
||||
<item quantity="one">%1$d d overdue</item>
|
||||
<item quantity="other">%1$d d overdue</item>
|
||||
</plurals>
|
||||
<string name="widget_lists_label">Lists</string>
|
||||
<string name="widget_lists_description">A shortcut tile per list, with its open count</string>
|
||||
<string name="widget_lists_empty">No lists to show</string>
|
||||
<string name="widget_lists_config_title">Lists to show</string>
|
||||
<string name="widget_week_label">Week</string>
|
||||
<string name="widget_week_description">A seven-day strip of task counts</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="56dp"
|
||||
android:minHeight="56dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:minResizeWidth="56dp"
|
||||
android:minResizeHeight="56dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_glance_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetFeatures="configuration_optional"
|
||||
android:previewLayout="@layout/widget_preview_glance"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_lists_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewLayout="@layout/widget_preview_lists"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_today_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewLayout="@layout/widget_preview_today"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="56dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="1"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="56dp"
|
||||
android:resizeMode="horizontal"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_up_next_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewLayout="@layout/widget_preview_up_next"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="56dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="1"
|
||||
android:minResizeWidth="250dp"
|
||||
android:minResizeHeight="56dp"
|
||||
android:resizeMode="vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_week_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewLayout="@layout/widget_preview_week"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -10,6 +10,9 @@ fun testTask(
|
||||
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
priority: Priority = Priority.NONE,
|
||||
due: Instant? = null,
|
||||
isAllDay: Boolean = false,
|
||||
completedAt: Instant? = null,
|
||||
parentId: Long? = null,
|
||||
): Task = Task(
|
||||
taskId = id,
|
||||
listId = listId,
|
||||
@@ -22,14 +25,14 @@ fun testTask(
|
||||
percentComplete = null,
|
||||
start = null,
|
||||
due = due,
|
||||
isAllDay = false,
|
||||
isAllDay = isAllDay,
|
||||
timeZone = null,
|
||||
completedAt = null,
|
||||
completedAt = completedAt,
|
||||
listColor = 0,
|
||||
taskColor = null,
|
||||
listName = null,
|
||||
accountName = null,
|
||||
parentId = null,
|
||||
parentId = parentId,
|
||||
isRecurring = false,
|
||||
occurrenceStart = null,
|
||||
distanceFromCurrent = 0,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package de.jeanlucmakiola.agendula.widget
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||
import de.jeanlucmakiola.agendula.domain.comparator
|
||||
import de.jeanlucmakiola.agendula.domain.testTask
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
class WidgetStatsTest {
|
||||
|
||||
private val newYork = ZoneId.of("America/New_York")
|
||||
private val today = LocalDate.of(2026, 7, 20)
|
||||
private val comparator = TaskSortOrder.DUE.comparator()
|
||||
|
||||
private fun at(local: String, zone: ZoneId = newYork): Instant =
|
||||
Instant.fromEpochMilliseconds(LocalDateTime.parse(local).atZone(zone).toInstant().toEpochMilli())
|
||||
|
||||
private val now = at("2026-07-20T12:00:00")
|
||||
|
||||
// --- todayProgress ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a task completed today counts as done, one completed yesterday does not`() {
|
||||
val completed = listOf(
|
||||
testTask(id = 1, status = TaskStatus.COMPLETED, completedAt = at("2026-07-20T09:00:00")),
|
||||
testTask(id = 2, status = TaskStatus.COMPLETED, completedAt = at("2026-07-19T23:00:00")),
|
||||
)
|
||||
val (done, _, _) = todayProgress(emptyList(), completed, comparator, now, today, newYork)
|
||||
assertThat(done).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day task due today and an overdue timed task both count as remaining`() {
|
||||
val open = listOf(
|
||||
testTask(id = 1, due = allDayInstantOf(today), isAllDay = true),
|
||||
testTask(id = 2, due = at("2026-07-20T08:00:00")), // already past `now` (12:00)
|
||||
testTask(id = 3, due = at("2026-07-21T08:00:00")), // tomorrow, not remaining
|
||||
)
|
||||
val (_, remaining, _) = todayProgress(open, emptyList(), comparator, now, today, newYork)
|
||||
assertThat(remaining).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an earlier-due remaining task sorts ahead of a later one`() {
|
||||
val open = listOf(
|
||||
testTask(id = 1, due = at("2026-07-20T11:00:00")), // overdue, closer to now
|
||||
testTask(id = 2, due = at("2026-07-20T07:00:00")), // more overdue, earliest
|
||||
)
|
||||
val (_, _, next) = todayProgress(open, emptyList(), comparator, now, today, newYork)
|
||||
assertThat(next?.taskId).isEqualTo(2)
|
||||
}
|
||||
|
||||
// --- nextDue -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `nextDue picks the earliest due task, overdue included, and skips undated ones`() {
|
||||
val open = listOf(
|
||||
testTask(id = 1, due = null),
|
||||
testTask(id = 2, due = at("2026-07-21T08:00:00")),
|
||||
testTask(id = 3, due = at("2026-07-20T08:00:00")), // overdue, earliest
|
||||
)
|
||||
assertThat(nextDue(open, now, today, newYork)?.taskId).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nextDue is null when nothing is dated`() {
|
||||
assertThat(nextDue(listOf(testTask(due = null)), now, today, newYork)).isNull()
|
||||
}
|
||||
|
||||
// --- weekDays ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `today's bucket folds in overdue tasks from earlier days`() {
|
||||
val open = listOf(
|
||||
testTask(id = 1, due = at("2026-07-18T08:00:00")), // two days overdue
|
||||
testTask(id = 2, due = at("2026-07-20T08:00:00")), // due today, already past now
|
||||
)
|
||||
val week = weekDays(open, comparator, now, today, newYork)
|
||||
assertThat(week.first().date).isEqualTo(today)
|
||||
assertThat(week.first().count).isEqualTo(2)
|
||||
assertThat(week.first().overdueCount).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a future day only counts tasks due that exact day`() {
|
||||
val open = listOf(
|
||||
testTask(id = 1, due = at("2026-07-22T08:00:00")),
|
||||
testTask(id = 2, due = at("2026-07-23T08:00:00")),
|
||||
)
|
||||
val week = weekDays(open, comparator, now, today, newYork)
|
||||
assertThat(week[2].count).isEqualTo(1) // today + 2 = 07-22
|
||||
assertThat(week[2].overdueCount).isEqualTo(0)
|
||||
assertThat(week[3].count).isEqualTo(1) // today + 3 = 07-23
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day at the far edge of daylight saving still buckets by local date`() {
|
||||
// Spans the US spring-forward boundary; only exercised to confirm
|
||||
// calendarDate (not a raw day-of-year add) drives the bucketing.
|
||||
val dstToday = LocalDate.of(2026, 3, 7)
|
||||
val dstNow = at("2026-03-07T12:00:00")
|
||||
val open = listOf(testTask(id = 1, due = at("2026-03-08T08:00:00")))
|
||||
val week = weekDays(open, comparator, dstNow, dstToday, newYork)
|
||||
assertThat(week[1].date).isEqualTo(LocalDate.of(2026, 3, 8))
|
||||
assertThat(week[1].count).isEqualTo(1)
|
||||
}
|
||||
|
||||
// --- listTiles -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `listTiles counts open tasks per visible list and skips a subtask whose parent is also open`() {
|
||||
val lists = listOf(
|
||||
TaskList(1, "Home", 0, "a", "t", false, isVisible = true, owner = null),
|
||||
TaskList(2, "Hidden", 0, "a", "t", false, isVisible = false, owner = null),
|
||||
)
|
||||
val open = listOf(
|
||||
testTask(id = 1, listId = 1),
|
||||
testTask(id = 2, listId = 1, parentId = 1), // subtask of an open parent
|
||||
testTask(id = 3, listId = 2),
|
||||
)
|
||||
val tiles = listTiles(open, lists)
|
||||
assertThat(tiles).hasSize(1)
|
||||
assertThat(tiles.single().id).isEqualTo(1L)
|
||||
assertThat(tiles.single().openCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule floret-kit updated: 435447eae2...d7010b0b82
Reference in New Issue
Block a user