Author SHA1 Message Date
makiolaj ebaf5179de feat: allow editing synced lists, not just device-only ones
The edit pencil was gated to local lists even though rename/delete already
worked end to end for synced collections. Read-only shares keep the icon
but show it disabled, with a hint on tap explaining why.

Refs #29
2026-10-02 18:32:17 +02:00
makiolaj 55fafb66c2 fix: remove double inset on the list editor's Where row
Refs #30
2026-10-02 18:32:17 +02:00
Jean-Luc Makiolaandmakiolaj 7eb1ebbfa3 feat(widget): widget redesign and new Today/Up next/Lists/Week widgets (#33)
Reworks the home-screen widgets on top of #32.

- Tasks widget: agenda-style rows, a row-colour setting, an "All caught up" empty state
- Drops "At a glance" (it duplicated the Tasks widget) for four new widgets: Today (progress ring + today's tasks), Up next (the nearest due task, optionally from one list), Lists (adaptive shortcut grid), Week (day strip with counts)
- Ticking a task in any widget shows a done frame before the row goes away
- Shared Glance primitives live in floret-kit (`feat/glance-module`): progress ring, tiles, check button, header action pill, expressive shapes, done state

Today and Up next don't offer the transparent background, since their content isn't in containers and is unreadable on the wallpaper. No issue behind this one; it came out of reviewing the widgets on-device.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/33
2026-10-02 18:20:28 +02:00
Jean-Luc Makiolaandmakiolaj 7a82f7c3fa feat: let a widget show tasks from several lists (#32)
Widget config now multi-selects real lists (smart lists stay
single-select, mutually exclusive) and offers an optional "group by
list" sort that heads each list's tasks with its name.

Closes #25

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/32
2026-10-02 18:18:08 +02:00
Jean-Luc Makiolaandmakiolaj 53c518fbe2 feat: setting to default a new task's start to today (#31)
A new Settings → Task form toggle, off by default, pre-fills a fresh
task's start with the current moment instead of leaving it blank.

Closes #27

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/31
2026-10-02 18:17:36 +02:00
Jean-Luc Makiola 53d5e2c5aa Translations update from Weblate (#26)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/26
2026-10-02 18:13:09 +02:00
Robin Schanbacher 6888965f5a Translated using Weblate (German)
Currently translated at 62.4% (359 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-01 21:35:13 +00:00
Robin Schanbacher f1ba21e7fa Translated using Weblate (German)
Currently translated at 57.3% (330 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-01 19:19:02 +00:00
Robin Schanbacher 8abc44bbdf Translated using Weblate (German)
Currently translated at 57.0% (328 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-09-30 20:57:48 +00:00
Robin Schanbacher a6e3a35f04 Translated using Weblate (German)
Currently translated at 45.0% (259 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-09-29 20:37:10 +00:00
Jean-Luc Makiolaandmakiolaj a3de9f3d93 docs(readme): show the store screenshots (#24)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Adds the five phone screenshots from `fastlane/metadata/android/en-US/images/phoneScreenshots/` to the README header, as a centered row beneath the badges.

The README references the fastlane files directly rather than copies, so the README and the store listings stay in sync. Each image has descriptive alt text.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/24
2026-09-28 14:11:27 +02:00
Jean-Luc Makiola c823243e53 Merge pull request 'docs: sync the F-Droid recipe with the submitted one' (#22) from docs/fdroid-recipe into main
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
2026-09-24 22:35:14 +02:00
54 changed files with 3115 additions and 601 deletions
+8
View File
@@ -20,6 +20,14 @@ required.</p>
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p>
<p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" alt="Home screen: today's progress, overdue and upcoming tasks, and your lists" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" alt="Onboarding: sync with your own CalDAV server" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" alt="Upcoming list with a task expanded into its subtasks" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" alt="Task detail: a monthly recurring task with a reminder and priority" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" alt="Reminder notification with Done and Snooze actions" width="18%">
</p>
</div>
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
+1
View File
@@ -244,6 +244,7 @@ dependencies {
implementation(libs.floret.core.crash)
implementation(libs.floret.identity)
implementation(libs.floret.components)
implementation(libs.floret.glance)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
+52
View File
@@ -185,6 +185,58 @@
android:resource="@xml/appwidget_info_tasks" />
</receiver>
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
<receiver
android:name=".widget.TodayWidgetReceiver"
android:label="@string/widget_today_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_today" />
</receiver>
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
<receiver
android:name=".widget.UpNextWidgetReceiver"
android:label="@string/widget_up_next_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_up_next" />
</receiver>
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
<receiver
android:name=".widget.ListsWidgetReceiver"
android:label="@string/widget_lists_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_lists" />
</receiver>
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
<receiver
android:name=".widget.WeekWidgetReceiver"
android:label="@string/widget_week_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_week" />
</receiver>
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
<activity
android:name=".widget.TaskWidgetConfigActivity"
@@ -186,6 +186,7 @@ class MainActivity : ComponentActivity() {
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L
@@ -206,9 +207,14 @@ class MainActivity : ComponentActivity() {
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** The launcher shortcut's and Quick Settings tile's "New task". */
fun newTaskIntent(context: Context): Intent =
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
/**
* The launcher shortcut's, Quick Settings tile's and a widget's "New
* task". [listId] preset the list when it names exactly one; the data
* URI keeps each widget's PendingIntent apart from the others.
*/
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
fun todayIntent(context: Context): Intent =
@@ -233,7 +239,9 @@ class MainActivity : ComponentActivity() {
?.let { return NavRequest.OpenAccount(it) }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask()
ACTION_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
)
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
@@ -95,6 +95,8 @@ data class Settings(
val pushEnabled: Boolean = true,
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false,
) {
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -153,6 +155,7 @@ class SettingsPrefs @Inject constructor(
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
.toSet()
},
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
)
}
@@ -166,6 +169,8 @@ class SettingsPrefs @Inject constructor(
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
suspend fun setDefaultStartToday(enabled: Boolean) = dataStore.edit { it[DEFAULT_START_TODAY] = enabled }
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
@@ -282,6 +287,7 @@ class SettingsPrefs @Inject constructor(
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
}
}
@@ -63,6 +63,9 @@ class TasksRepositoryImpl @Inject constructor(
// parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
// several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
}
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -8,6 +8,8 @@ enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
/** What a task screen is showing: one real list, or a smart list. */
sealed interface TaskFilter {
data class OfList(val listId: Long) : TaskFilter
/** Several lists shown together, e.g. one widget covering more than one list. */
data class OfLists(val listIds: Set<Long>) : TaskFilter
data class Smart(val list: SmartList) : TaskFilter
}
@@ -21,6 +23,7 @@ object TaskFiltering {
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
when (filter) {
is TaskFilter.OfList -> task.listId == filter.listId
is TaskFilter.OfLists -> task.listId in filter.listIds
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
}
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Clock
import kotlin.time.Instant
/**
@@ -122,10 +123,11 @@ class TaskEditViewModel @Inject constructor(
val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
// A read-only or vanished default would give the task a list it
// A read-only or vanished default — or a widget's preset list, if it was
// since deleted or turned read-only — would give the task a list it
// cannot be saved to.
val writable = lists.filter { it.acceptsWrites }
val defaultList = presetListId
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
?: writable.firstOrNull { !it.isLocal }?.id
?: writable.firstOrNull()?.id
@@ -135,6 +137,7 @@ class TaskEditViewModel @Inject constructor(
isNew = true,
autofocusTitle = settings.autofocusTitle,
listId = defaultList,
start = if (settings.defaultStartToday) Clock.System.now() else null,
parentId = parentId,
lists = lists,
parentCandidates = loadParents(defaultList, selfId = null),
@@ -153,7 +153,6 @@ fun ListEditorSheet(
GroupedRow(
title = destination.label,
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
leading = { Icon(destinationIcon(destination), contentDescription = null) },
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
onClick = { choosingDestination = true },
@@ -52,7 +52,8 @@ fun AgendulaNavHost(
nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true }
is NavRequest.OpenAccount ->
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
is NavRequest.NewTask -> nav.navigate(Dest.TaskEdit.buildNew(title = navRequest.title))
is NavRequest.NewTask ->
nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
launchSingleTop = true
}
@@ -69,6 +69,8 @@ object Dest {
fun build(filter: TaskFilter): String = when (filter) {
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
}
/** Reconstruct the filter from a destination's arguments. */
@@ -18,8 +18,11 @@ sealed interface NavRequest {
/** One account's detail — where "sign in again" lives. */
data class OpenAccount(val accountId: Long) : NavRequest
/** A new task, optionally titled with text shared from another app. */
data class NewTask(val title: String? = null) : NavRequest
/**
* A new task, optionally titled with text shared from another app, or
* preset to a list — a widget's own add button, when it shows one list.
*/
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
data class OpenSmart(val list: SmartList) : NavRequest
@@ -241,6 +241,7 @@ class SettingsViewModel @Inject constructor(
fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
fun setDefaultStartToday(enabled: Boolean) = viewModelScope.launch { prefs.setDefaultStartToday(enabled) }
/** Toggle whether [field] shows by default on a new task's edit form. */
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
@@ -99,6 +99,18 @@ internal fun TaskFormScreen(
},
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
)
GroupedRow(
title = stringResource(R.string.settings_default_start_today),
summary = stringResource(R.string.settings_default_start_today_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.settings.defaultStartToday,
onCheckedChange = viewModel::setDefaultStartToday,
)
},
onClick = { viewModel.setDefaultStartToday(!state.settings.defaultStartToday) },
)
GroupedRow(
title = stringResource(R.string.settings_bottom_add_bar),
summary = stringResource(R.string.settings_bottom_add_bar_hint),
@@ -52,6 +52,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
@@ -139,9 +140,12 @@ fun TaskListScreen(
val list = content?.list
val listName = list?.name
val listId = (filter as? TaskFilter.OfList)?.listId
// Editing is offered for a device-only list. A collection that belongs to an
// account is the server's to rename or remove, not ours.
// Editing is offered for any writable list, device or synced — renaming and
// recolouring a synced collection is a server write the same sheet already
// handles. A read-only share keeps the icon but disables it: tapping it
// explains why rather than the button silently not being there.
var editingList by rememberSaveable { mutableStateOf(false) }
var readOnlyHint by rememberSaveable { mutableStateOf(false) }
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
// The list this screen is about is gone; there is nothing left to show.
@@ -152,6 +156,12 @@ fun TaskListScreen(
viewModel.clearListWriteFailure()
}
}
LaunchedEffect(readOnlyHint) {
if (readOnlyHint) {
delay(4_000)
readOnlyHint = false
}
}
// One add affordance, never two: a real list with the setting on gets a pinned
// bottom quick-add bar; everything else (incl. smart lists, which have no single
// target list) gets the floating "New task" button.
@@ -181,11 +191,20 @@ fun TaskListScreen(
IconButton(onClick = { showSort = true }, enabled = content != null) {
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
}
if (list != null && list.isLocal) {
IconButton(onClick = { editingList = true }) {
if (list != null) {
IconButton(
onClick = {
if (list.isReadOnly) readOnlyHint = true else editingList = true
},
) {
Icon(
Icons.Rounded.Edit,
contentDescription = stringResource(R.string.list_edit_title),
tint = if (list.isReadOnly) {
LocalContentColor.current.copy(alpha = 0.38f)
} else {
LocalContentColor.current
},
)
}
}
@@ -230,23 +249,28 @@ fun TaskListScreen(
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
) {
// One chip, one anchor: the undo receipt takes precedence, and a
// refused list write reports itself once the undo window is clear.
// One chip, one anchor, in priority order: the undo receipt first,
// then a refused list write once the undo window is clear, then the
// read-only edit hint — the quietest of the three, so it never
// outranks something the other two are actually reporting.
val failure = listWriteFailure
if (undo != null || failure == null) {
SnackChip(
visible = undo != null,
when {
undo != null -> SnackChip(
visible = true,
message = stringResource(
if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted,
),
actionLabel = stringResource(R.string.undo),
onAction = viewModel::undo,
)
} else {
SnackChip(
failure != null -> SnackChip(
visible = true,
message = stringResource(listWriteFailureMessage(failure)),
)
else -> SnackChip(
visible = readOnlyHint,
message = stringResource(R.string.list_read_only),
)
}
}
}
@@ -1148,6 +1172,8 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
@Composable
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
is TaskFilter.Smart -> stringResource(
when (filter.list) {
SmartList.TODAY -> R.string.smart_today
@@ -103,6 +103,8 @@ class TaskListViewModel @Inject constructor(
}
is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) }
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
}
// Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit,
@@ -0,0 +1,235 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.color.ColorProvider
import androidx.glance.currentState
import androidx.glance.unit.ColorProvider as GlanceColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetActionButton
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.glance.WidgetTile
import de.jeanlucmakiola.floret.glance.R as GlanceR
import kotlinx.coroutines.flow.first
import kotlin.math.min
/**
* "Lists" home-screen widget: one shortcut tile per list, in the list's own
* colour, showing its open count — navigation to a list rather than a
* preview of its tasks, which is what [TaskWidget] already does.
*/
class ListsWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact, not Responsive: the grid is sized to the real placement so every
// tile fits without scrolling.
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
provideContent {
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val listIds = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
FloretGlanceTheme(
lightScheme = AgendulaLightFallback,
darkScheme = AgendulaDarkFallback,
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
) {
ListsWidgetBody(data, background, listIds)
}
}
}
}
@Composable
private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listIds: Set<Long>?) {
val context = LocalContext.current
val ready = data as? WidgetStatsData.Ready
val tiles = ready?.snapshot?.lists?.let { lists -> listIds?.let { ids -> lists.filter { it.id in ids } } ?: lists }
val size = LocalSize.current
WidgetSurface(background = background) {
Column(modifier = GlanceModifier.fillMaxSize().padding(Padding - Gap / 2)) {
when {
data == null -> Unit
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
tiles.isNullOrEmpty() -> WidgetMessage(context.getString(R.string.widget_lists_empty))
else -> {
val grid = listGrid(tiles.size, size.width - Padding * 2, size.height - Padding * 2)
val rows = tiles.take(grid.columns * grid.rows).chunked(grid.columns)
// Gaps are each tile's padding rather than Spacers, which would
// eat into Glance's 10-children-per-Row/Column cap.
rows.forEach { rowTiles ->
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
rowTiles.forEach { tile ->
// Glance has no margin — padding on the tile itself lands inside its fill.
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(Gap / 2)) {
ListTileCell(tile, grid, GlanceModifier.fillMaxSize())
}
}
}
}
}
}
}
}
}
internal data class ListGrid(val columns: Int, val rows: Int, val tileWidth: Dp, val tileHeight: Dp)
/**
* Picks the column count whose tiles come closest to [TileAspect] at the
* largest size, among those that fit [count] tiles into [width] x [height].
* Tiles too narrow for the one-line layout must be tall enough to stack, so a
* narrow widget shows fewer, readable tiles. Lists that don't fit are dropped
* rather than scrolled.
*/
internal fun listGrid(count: Int, width: Dp, height: Dp): ListGrid {
fun span(total: Dp, cells: Int) = (total - Gap * (cells - 1)) / cells
fun maxRows(columns: Int): Int {
val minHeight = if (span(width, columns) < MinOneLineTileWidth) MinNarrowTileHeight else MinTileHeight
return ((height + Gap) / (minHeight + Gap)).toInt().coerceIn(1, MaxCells)
}
val maxColumns = ((width + Gap) / (MinTileWidth + Gap)).toInt().coerceIn(1, MaxCells)
val best = (1..min(count, maxColumns))
.map { columns -> columns to (count + columns - 1) / columns }
.filter { (columns, rows) -> rows <= maxRows(columns) }
.maxByOrNull { (columns, rows) -> min(span(width, columns).value / TileAspect, span(height, rows).value) }
val (columns, rows) = best ?: (maxColumns to maxRows(maxColumns))
return ListGrid(columns, rows, span(width, columns), span(height, rows))
}
@Composable
private fun ListTileCell(tile: ListTile, grid: ListGrid, modifier: GlanceModifier) {
val context = LocalContext.current
val tint = ColorProvider(day = pastelize(tile.color, false), night = pastelize(tile.color, true))
val onTint = GlanceColorProvider(Color.White)
val showAdd = tile.acceptsWrites && grid.tileWidth >= MinTileWidthWithAdd
WidgetTile(
color = tint,
modifier = modifier,
onClick = actionStartActivity(MainActivity.listIntent(context, tile.id)),
) {
if (grid.tileWidth < MinOneLineTileWidth && grid.tileHeight >= MinNarrowTileHeight) {
// Narrow: the name gets the full width and wraps; count and + share the bottom row.
Column(modifier = GlanceModifier.fillMaxSize()) {
Text(
text = tile.name,
maxLines = if (grid.tileHeight >= MinTwoLineNameHeight) 2 else 1,
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
)
Spacer(GlanceModifier.defaultWeight())
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${tile.openCount}",
modifier = GlanceModifier.defaultWeight(),
style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium),
)
if (showAdd) ListAddButton(tile)
}
}
} else if (grid.tileHeight >= MinStackedTileHeight) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = tile.name,
maxLines = 1,
modifier = GlanceModifier.defaultWeight(),
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
)
if (showAdd) ListAddButton(tile)
}
Spacer(GlanceModifier.defaultWeight())
Text(
text = "${tile.openCount}",
style = TextStyle(color = onTint, fontSize = 18.sp, fontWeight = FontWeight.Medium),
)
}
} else {
Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = tile.name,
maxLines = 1,
modifier = GlanceModifier.defaultWeight(),
style = TextStyle(color = onTint, fontSize = 13.sp, fontWeight = FontWeight.Medium),
)
Text(
text = "${tile.openCount}",
modifier = GlanceModifier.padding(horizontal = 4.dp),
style = TextStyle(color = onTint, fontSize = 15.sp, fontWeight = FontWeight.Bold),
)
if (showAdd) ListAddButton(tile)
}
}
}
}
@Composable
private fun ListAddButton(tile: ListTile) {
val context = LocalContext.current
WidgetActionButton(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context, tile.id)),
style = WidgetActionStyle.TONAL,
size = 20.dp,
)
}
private val Padding = 8.dp
private val Gap = 8.dp
private val MinTileWidth = 72.dp
private val MinTileHeight = 36.dp
private val MinTileWidthWithAdd = 64.dp
private val MinStackedTileHeight = 60.dp
private val MinNarrowTileHeight = 52.dp
private val MinTwoLineNameHeight = 70.dp
private val MinOneLineTileWidth = 120.dp
private const val TileAspect = 1.5f
/** Glance's per-Row/Column child cap. */
private const val MaxCells = 10
@@ -0,0 +1,27 @@
package de.jeanlucmakiola.agendula.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
/** Host-facing receiver for [ListsWidget]; also keeps the midnight rollover armed. */
class ListsWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = ListsWidget()
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
}
@@ -1,17 +1,18 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.action.ActionParameters
@@ -22,12 +23,10 @@ import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.background
import androidx.glance.color.ColorProvider
import androidx.glance.currentState
import androidx.glance.layout.Alignment
@@ -44,20 +43,40 @@ import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextDecoration
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider as GlanceColorProvider
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetCheckButton
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import de.jeanlucmakiola.floret.glance.WidgetDoneState
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.floret.glance.R as GlanceR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlin.time.Instant
/**
* "Tasks" home-screen widget: one smart list (Today by default) or one real
* list per instance, with a checkbox to complete each task in place.
* "Tasks" home-screen widget: one smart list (Today by default) or one or more
* real lists per instance, with a Calendar-agenda look — a plain section label
* per group (by list, or by due bucket: Overdue / Today / Tomorrow / Later /
* No date) above a run of standalone, fully-rounded task pills, each with a
* trailing check to complete it in place.
*
* The data is collected live inside the composition rather than loaded once in
* the `provideGlance` preamble: an `update` on a running session only
@@ -68,200 +87,295 @@ class TaskWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Single
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, RegularSize))
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY]
val initialFilter = parseWidgetFilter(stored)
val initial = runCatching { context.taskWidgetData(initialFilter).first() }
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
val initialFilter = parseWidgetFilter(state[WIDGET_FILTER_KEY])
val initialGroupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
val initial = runCatching { context.taskWidgetData(initialFilter, initialGroupByList).first() }
.getOrDefault(TaskWidgetData.Unavailable)
provideContent {
val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY))
val data by remember(filter) { context.taskWidgetData(filter) }
.collectAsState(initial = if (filter == initialFilter) initial else null)
val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val rowColor = parseWidgetRowColor(currentState(WIDGET_ROW_COLOR_KEY))
val data by remember(filter, groupByList) { context.taskWidgetData(filter, groupByList) }
.collectAsState(initial = if (filter == initialFilter && groupByList == initialGroupByList) initial else null)
val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
AgendulaGlanceTheme(
FloretGlanceTheme(
lightScheme = AgendulaLightFallback,
darkScheme = AgendulaDarkFallback,
dynamicColor = ready?.dynamicColor ?: true,
themeMode = ready?.themeMode ?: ThemeMode.SYSTEM,
mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM,
) {
TaskWidgetBody(filter, data)
TaskWidgetBody(filter, data, background, rowColor)
}
}
}
private companion object {
val CompactSize = DpSize(110.dp, 110.dp)
val RegularSize = DpSize(250.dp, 110.dp)
}
}
/** Completes the tapped task — just the one occurrence for a recurring series. */
class CompleteTaskAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val taskId = parameters[TaskIdKey] ?: return
val occurrence = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE }
?.let { Instant.fromEpochMilliseconds(it) }
val occurrenceMillis = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE }
val key = WidgetCompletions.key(taskId, occurrenceMillis)
if (!WidgetCompletions.start(key)) return
val app = context.applicationContext
// This callback is shared by every widget's rows, and glanceId only
// tells us the tapped widget's instance, not which provider class it
// belongs to — updating the wrong one by id would push this class's
// RemoteViews into another widget's AppWidget slot. updateAll is
// class-scoped, so redrawing every widget is the immediate, correct
// option; it also covers every other open instance showing this task.
// First the ticked frame, then the real completion a beat later.
updateAllWidgets(app)
delay(TICK_FRAME_MILLIS)
runCatching {
context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true)
context.widgetEntryPoint().tasksRepository()
.setCompleted(taskId, occurrenceMillis?.let { Instant.fromEpochMilliseconds(it) }, true)
}
TaskWidget().update(context.applicationContext, glanceId)
updateAllWidgets(app)
// Hold the ticked look until the store's change has redrawn the row away.
delay(TICK_RELEASE_MILLIS)
WidgetCompletions.finish(key)
updateAllWidgets(app)
}
companion object {
val TaskIdKey = ActionParameters.Key<Long>("taskId")
val OccurrenceKey = ActionParameters.Key<Long>("occurrenceStart")
const val NO_OCCURRENCE = Long.MIN_VALUE
private const val TICK_FRAME_MILLIS = 600L
private const val TICK_RELEASE_MILLIS = 1500L
}
}
@Composable
private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
private fun TaskWidgetBody(
filter: TaskFilter,
data: TaskWidgetData?,
background: WidgetBackground,
rowColor: WidgetRowColor,
) {
val context = LocalContext.current
val ready = data as? TaskWidgetData.Ready
val title = when (filter) {
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
is TaskFilter.OfList -> (data as? TaskWidgetData.Ready)?.listName
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
?: context.getString(R.string.widget_tasks_label)
}
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Header(title, filter)
Spacer(GlanceModifier.height(4.dp))
when (data) {
null -> Unit
TaskWidgetData.Unavailable -> Message(context.getString(R.string.widget_unavailable))
is TaskWidgetData.Ready ->
if (data.rows.isEmpty()) {
Message(context.getString(R.string.widget_empty))
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(data.rows.size) { index -> TaskRow(data.rows[index], data.use24Hour) }
}
}
}
}
}
@Composable
private fun Header(title: String, filter: TaskFilter) {
val context = LocalContext.current
val open = when (filter) {
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
}
Row(
modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier.defaultWeight().clickable(actionStartActivity(open)),
)
Box(
modifier = GlanceModifier
.size(40.dp)
.cornerRadius(20.dp)
.background(GlanceTheme.colors.primaryContainer)
.clickable(actionStartActivity(MainActivity.newTaskIntent(context))),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.new_task),
colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer),
modifier = GlanceModifier.size(22.dp),
)
}
}
}
@Composable
private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val open = actionStartActivity(
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
)
Row(
modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(4.dp)
.height(32.dp)
.cornerRadius(2.dp)
.background(ColorProvider(day = pastelize(row.color, false), night = pastelize(row.color, true))),
) {}
Box(
modifier = GlanceModifier
.size(40.dp)
.clickable(
actionRunCallback<CompleteTaskAction>(
actionParametersOf(
CompleteTaskAction.TaskIdKey to row.taskId,
CompleteTaskAction.OccurrenceKey to
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
),
),
),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(R.drawable.ic_widget_checkbox),
contentDescription = context.getString(R.string.widget_complete_task, title),
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(22.dp),
)
}
WidgetSurface(background = background) {
Column(
modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp)
.clickable(open),
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
text = title,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
)
dueText(context, row, use24Hour)?.let { due ->
Text(
text = due,
maxLines = 1,
style = TextStyle(
color = if (row.isOverdue) GlanceTheme.colors.error
else GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp,
),
)
TaskWidgetHeader(title, filter, ready?.singleListId)
Spacer(GlanceModifier.height(2.dp))
when (data) {
null -> Unit
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
is TaskWidgetData.Ready ->
if (data.rows.isEmpty()) {
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
} else {
val allSections = widgetSections(
data.rows,
data.groupByList,
data.listNames,
) { kind -> context.getString(dueBucketLabel(kind)) }
// A lone group whose bucket repeats the widget's own smart list
// (the "Today" smart list showing one "Today" bucket) earns no
// place of its own. A lone group for any other bucket (a
// real-list widget whose only bucket is "Tomorrow") still has to
// say so — it's the one thing telling the rows apart from today.
// Compared by due kind, not by rendered label text, since the two
// labels come from separately translated string resources and
// aren't guaranteed to read identically in every locale.
val onlyHeader = allSections.singleOrNull { it is WidgetSection.Header } as? WidgetSection.Header
val redundantDueKind = when ((filter as? TaskFilter.Smart)?.list) {
SmartList.TODAY -> DueKind.TODAY
SmartList.OVERDUE -> DueKind.PAST
else -> null
}
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
onlyHeader.key == "due:${redundantDueKind.name}"
val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(sections.size) { index ->
when (val section = sections[index]) {
is WidgetSection.Header -> SectionHeader(section.name)
is WidgetSection.TaskItem -> {
val container = if (rowColor == WidgetRowColor.BY_LIST) {
ColorProvider(
day = pastelize(section.row.color, false),
night = pastelize(section.row.color, true),
)
} else {
GlanceTheme.colors.surfaceVariant
}
// A LazyColumn item's host view isn't a linear layout on
// its own, so the row and its gap need an explicit Column
// to stack rather than overlap.
Column {
AgendaTaskRow(
row = section.row,
use24Hour = data.use24Hour,
color = container,
colored = rowColor == WidgetRowColor.BY_LIST,
// The due-bucket header above already names the day
// (Today / Tomorrow) whenever one is shown — and when
// it isn't, that's only because it would have repeated
// the title, which names the same day just as well.
suppressDayLabel = !data.groupByList,
)
Spacer(GlanceModifier.height(8.dp))
}
}
}
}
}
}
}
}
}
}
private fun dueBucketLabel(kind: DueKind?): Int = when (kind) {
DueKind.PAST -> R.string.widget_section_overdue
DueKind.TODAY -> R.string.home_due_today
DueKind.TOMORROW -> R.string.home_due_tomorrow
DueKind.LATER -> R.string.widget_section_later
null -> R.string.widget_section_no_date
}
@Composable
private fun Message(text: String) {
Box(
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
private fun SectionHeader(name: String) {
Text(
text = name,
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
)
}
@Composable
private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Long?) {
val context = LocalContext.current
val open = when (filter) {
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
// Several lists have no single list screen to open; a single selection
// still opens straight to it, otherwise this just raises the app.
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
WidgetHeader(
title = title,
titleAction = actionStartActivity(open),
) {
Text(
text = text,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
WidgetHeaderAction(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
)
}
}
/** "Today · 14:00", "Tomorrow", or the compact date; `null` without a due date. */
private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String? {
/**
* A Calendar-agenda-style task chip: its own fully-rounded pill (list colour
* or a neutral tone, per [WidgetRowColor]), a leading check to complete it,
* title and due to its right. [colored] pills use fixed light text/icon —
* [pastelize][de.jeanlucmakiola.floret.components.pastelize] keeps them
* mid-bright rather than pale, so the theme's own `onSurface` (tuned for the
* neutral tonal fill) reads too dark against them.
*/
@Composable
internal fun AgendaTaskRow(
row: WidgetTaskRow,
use24Hour: Boolean,
color: GlanceColorProvider,
colored: Boolean,
suppressDayLabel: Boolean,
) {
val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val open = actionStartActivity(
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
)
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
val onColorError = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.error
val completing = WidgetCompletions.isCompleting(row)
WidgetGroupedRow(position = Position.Alone, color = color) {
Row(
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
WidgetCheckButton(
contentDescription = context.getString(R.string.widget_complete_task, title),
action = actionRunCallback<CompleteTaskAction>(
actionParametersOf(
CompleteTaskAction.TaskIdKey to row.taskId,
CompleteTaskAction.OccurrenceKey to
(row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
),
),
tint = onColorVariant,
checked = completing,
)
Spacer(GlanceModifier.width(8.dp))
Column(modifier = GlanceModifier.defaultWeight().clickable(open)) {
Text(
text = title,
maxLines = 1,
style = TextStyle(
color = if (completing) onColorVariant else onColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None,
),
)
dueText(context, row, use24Hour, suppressDayLabel)?.let { due ->
Text(
text = due,
maxLines = 1,
style = TextStyle(
color = if (row.isOverdue) onColorError else onColorVariant,
fontSize = 12.sp,
),
)
}
}
}
}
}
/**
* The chip's own due line — bare "14:00" when [suppressDayLabel] (the due-bucket
* header above already says "Today"/"Tomorrow", Calendar-agenda style: the day
* is the group, the chip only needs the time), otherwise "Today · 14:00" /
* "Tomorrow", or the compact date for anything else. `null` without a due date,
* or for an all-day task once its day is already said by the header.
*/
internal fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean, suppressDayLabel: Boolean): String? {
val due = row.due ?: return null
val relative = when (row.dueKind) {
DueKind.TODAY -> context.getString(R.string.home_due_today)
DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow)
else -> null
} ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour)
return if (row.isAllDay) relative else "$relative · ${due.formatTime(use24Hour)}"
if (row.isAllDay) return if (suppressDayLabel) null else relative
val time = due.formatTime(use24Hour)
return if (suppressDayLabel) time else "$relative · $time"
}
@@ -1,18 +1,24 @@
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
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
@@ -22,6 +28,8 @@ import androidx.compose.runtime.remember
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
@@ -40,17 +48,50 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Which widget provider a configure session is for — decides which sections [WidgetFilterPicker] shows. */
internal enum class WidgetKind { TASKS, TODAY, UP_NEXT, LISTS, WEEK }
/** Reads [appWidgetId]'s provider class from the host, since that's the only thing naming which widget this is. */
private fun widgetKindOf(context: Context, appWidgetId: Int): WidgetKind? {
val provider = AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider ?: return null
return when (provider.className) {
TaskWidgetReceiver::class.java.name -> WidgetKind.TASKS
TodayWidgetReceiver::class.java.name -> WidgetKind.TODAY
UpNextWidgetReceiver::class.java.name -> WidgetKind.UP_NEXT
ListsWidgetReceiver::class.java.name -> WidgetKind.LISTS
WeekWidgetReceiver::class.java.name -> WidgetKind.WEEK
else -> null
}
}
private fun widgetFor(kind: WidgetKind): GlanceAppWidget = when (kind) {
WidgetKind.TASKS -> TaskWidget()
WidgetKind.TODAY -> TodayWidget()
WidgetKind.UP_NEXT -> UpNextWidget()
WidgetKind.LISTS -> ListsWidget()
WidgetKind.WEEK -> WeekWidget()
}
/**
* Picks what one widget instance shows: a smart list or a real list. Runs when
* the widget is placed (below API 31) and on "reconfigure" from the launcher.
* Picks what one widget instance shows. [WidgetKind.TASKS] picks a smart
* list or one or more real lists (with an optional "group by list" sort);
* [WidgetKind.LISTS] picks which lists get a tile; [WidgetKind.UP_NEXT] can be
* narrowed to one list; every other kind only
* offers background and theme, since they're always the smart-list scope.
* Runs when the widget is placed (below API 31) and on "reconfigure" from
* the launcher.
*/
@AndroidEntryPoint
class TaskWidgetConfigActivity : ComponentActivity() {
@@ -68,6 +109,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
finish()
return
}
val kind = widgetKindOf(this, appWidgetId) ?: WidgetKind.TASKS
enableEdgeToEdge()
val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId)
val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow())
@@ -77,10 +119,19 @@ class TaskWidgetConfigActivity : ComponentActivity() {
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
var current by remember { mutableStateOf<TaskFilter?>(null) }
var groupByList by remember { mutableStateOf(false) }
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
var themeOverride by remember { mutableStateOf<WidgetThemeMode?>(null) }
var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
var selectedListIds by remember { mutableStateOf<Set<Long>?>(null) }
LaunchedEffect(glanceId) {
current = parseWidgetFilter(
getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[WIDGET_FILTER_KEY],
)
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY], kind)
themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY])
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
}
val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -90,15 +141,40 @@ class TaskWidgetConfigActivity : ComponentActivity() {
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
WidgetFilterPicker(
kind = kind,
lists = lists.filter { it.isVisible },
dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER,
onSelect = { filter ->
groupByList = groupByList,
onGroupByListChange = { groupByList = it },
background = background,
onBackgroundChange = { background = it },
themeOverride = themeOverride,
onThemeOverrideChange = { themeOverride = it },
rowColor = rowColor,
onRowColorChange = { rowColor = it },
selectedListIds = selectedListIds,
onSelectedListIdsChange = { selectedListIds = it },
onSave = { filter ->
lifecycleScope.launch {
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
when (kind) {
WidgetKind.TASKS -> {
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
it[WIDGET_ROW_COLOR_KEY] = rowColor.name
}
WidgetKind.LISTS, WidgetKind.UP_NEXT -> {
selectedListIds?.let { ids -> it[WIDGET_LIST_IDS_KEY] = ids.widgetListIdsStorageValue() }
?: it.remove(WIDGET_LIST_IDS_KEY)
}
WidgetKind.TODAY, WidgetKind.WEEK -> Unit
}
it[WIDGET_BACKGROUND_KEY] = background.name
themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name }
?: it.remove(WIDGET_THEME_KEY)
}
TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
widgetFor(kind).update(this@TaskWidgetConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId))
finish()
}
@@ -115,40 +191,202 @@ class TaskWidgetConfigActivity : ComponentActivity() {
@Composable
private fun WidgetFilterPicker(
kind: WidgetKind,
lists: List<TaskList>,
selected: TaskFilter,
dark: Boolean,
onSelect: (TaskFilter) -> Unit,
groupByList: Boolean,
onGroupByListChange: (Boolean) -> Unit,
background: WidgetBackground,
onBackgroundChange: (WidgetBackground) -> Unit,
themeOverride: WidgetThemeMode?,
onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
rowColor: WidgetRowColor,
onRowColorChange: (WidgetRowColor) -> Unit,
selectedListIds: Set<Long>?,
onSelectedListIdsChange: (Set<Long>?) -> Unit,
onSave: (TaskFilter) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(title = stringResource(R.string.widget_config_title), onDismiss = onDismiss) {
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
val filter = TaskFilter.Smart(smart)
val isSelected = filter == selected
GroupedRow(
title = stringResource(smartListLabel(smart)),
position = positionOf(index, WIDGET_SMART_LISTS.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelect(filter) },
)
}
if (lists.isNotEmpty()) {
var showBackgroundPicker by remember { mutableStateOf(false) }
var showThemePicker by remember { mutableStateOf(false) }
var showRowColorPicker by remember { mutableStateOf(false) }
// Smart lists and real lists are mutually exclusive; only real lists support
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
// a smart-list pick; the reverse (or both empty, before the stored filter
// loads) is a list pick.
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
var selectedLists by remember(selected) {
mutableStateOf(
when (selected) {
is TaskFilter.OfList -> setOf(selected.listId)
is TaskFilter.OfLists -> selected.listIds
is TaskFilter.Smart -> emptySet()
},
)
}
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
FullScreenPicker(
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
onDismiss = onDismiss,
actions = {
TextButton(
enabled = canSave,
onClick = {
// The toggle row only shows (and so can only be turned off) with
// more than one list selected; narrowing back down to one, or to
// a smart list, has to clear it here instead; otherwise it's
// stuck on with no way left to see or undo it.
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
?: onSave(TaskFilter.OfLists(selectedLists))
},
) { Text(stringResource(android.R.string.ok)) }
},
) {
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 filter = TaskFilter.OfList(list.id)
val isSelected = filter == selected
// `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),
selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelect(filter) },
onClick = {
val current = selectedListIds ?: lists.map { it.id }.toSet()
onSelectedListIdsChange(if (isSelected) current - list.id else current + list.id)
},
)
}
Spacer(Modifier.height(24.dp))
} else if (kind == WidgetKind.UP_NEXT && lists.isNotEmpty()) {
// Single choice: `null` is every list, otherwise a one-element set.
val chosen = selectedListIds?.singleOrNull()
GroupedSectionHeader(stringResource(R.string.lists_header))
GroupedRow(
title = stringResource(R.string.widget_up_next_any_list),
position = positionOf(0, lists.size + 1),
selected = chosen == null,
trailing = if (chosen == null) ({ SelectedCheck() }) else null,
onClick = { onSelectedListIdsChange(null) },
)
lists.forEachIndexed { index, list ->
val isSelected = list.id == chosen
GroupedRow(
title = list.name,
position = positionOf(index + 1, lists.size + 1),
selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelectedListIdsChange(setOf(list.id)) },
)
}
Spacer(Modifier.height(24.dp))
}
GroupedRow(
title = stringResource(R.string.widget_config_background),
summary = stringResource(backgroundLabel(background)),
position = Position.Top,
onClick = { showBackgroundPicker = true },
)
if (kind == WidgetKind.TASKS) {
GroupedRow(
title = stringResource(R.string.widget_config_theme),
summary = stringResource(themeOverrideLabel(themeOverride)),
position = Position.Middle,
onClick = { showThemePicker = true },
)
GroupedRow(
title = stringResource(R.string.widget_config_row_color),
summary = stringResource(rowColorLabel(rowColor)),
position = Position.Bottom,
onClick = { showRowColorPicker = true },
)
} else {
GroupedRow(
title = stringResource(R.string.widget_config_theme),
summary = stringResource(themeOverrideLabel(themeOverride)),
position = Position.Bottom,
onClick = { showThemePicker = true },
)
}
}
if (showBackgroundPicker) {
OptionPicker(
title = stringResource(R.string.widget_config_background),
options = kind.backgrounds(),
selected = background,
label = { stringResource(backgroundLabel(it)) },
summary = { stringResource(backgroundHint(it)) },
onSelect = onBackgroundChange,
onDismiss = { showBackgroundPicker = false },
)
}
if (showThemePicker) {
OptionPicker(
title = stringResource(R.string.widget_config_theme),
options = listOf<WidgetThemeMode?>(null, WidgetThemeMode.LIGHT, WidgetThemeMode.DARK),
selected = themeOverride,
label = { stringResource(themeOverrideLabel(it)) },
onSelect = onThemeOverrideChange,
onDismiss = { showThemePicker = false },
)
}
if (showRowColorPicker) {
OptionPicker(
title = stringResource(R.string.widget_config_row_color),
options = WidgetRowColor.entries,
selected = rowColor,
label = { stringResource(rowColorLabel(it)) },
summary = { stringResource(rowColorHint(it)) },
onSelect = onRowColorChange,
onDismiss = { showRowColorPicker = false },
)
}
}
@@ -158,3 +396,32 @@ internal fun smartListLabel(list: SmartList): Int = when (list) {
SmartList.OVERDUE -> R.string.smart_overdue
else -> R.string.smart_all
}
private fun backgroundLabel(background: WidgetBackground): Int = when (background) {
WidgetBackground.SOLID -> R.string.widget_config_background_solid
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
}
private fun backgroundHint(background: WidgetBackground): Int = when (background) {
WidgetBackground.SOLID -> R.string.widget_config_background_solid_hint
WidgetBackground.TINTED -> R.string.widget_config_background_tinted_hint
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent_hint
}
private fun themeOverrideLabel(mode: WidgetThemeMode?): Int = when (mode) {
null -> R.string.widget_config_theme_follow_app
WidgetThemeMode.LIGHT -> R.string.widget_config_theme_light
WidgetThemeMode.DARK -> R.string.widget_config_theme_dark
WidgetThemeMode.SYSTEM -> R.string.widget_config_theme_follow_app
}
private fun rowColorLabel(rowColor: WidgetRowColor): Int = when (rowColor) {
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list
}
private fun rowColorHint(rowColor: WidgetRowColor): Int = when (rowColor) {
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral_hint
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list_hint
}
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
/** Host-facing receiver for [TaskWidget]; also keeps the midnight rollover armed. */
class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
@@ -11,17 +12,17 @@ class TaskWidgetReceiver : GlanceAppWidgetReceiver() {
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
/** Self-heal on the system's `updatePeriodMillis` wake-up, should the alarm have been dropped. */
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
}
@@ -1,10 +1,10 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
import androidx.glance.appwidget.updateAll
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.floret.glance.WidgetRollover
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -36,7 +36,9 @@ class TaskWidgetUpdater @Inject constructor(
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
}
scope.launch(Dispatchers.IO) { runCatching { WidgetRolloverScheduler.sync(context) } }
scope.launch(Dispatchers.IO) {
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
}
// The process may have been started by a change it never heard about.
requestRefresh()
}
@@ -48,7 +50,8 @@ class TaskWidgetUpdater @Inject constructor(
private suspend fun redraw() {
runCatching {
if (WidgetRolloverScheduler.hasPlacedWidgets(context)) TaskWidget().updateAll(context)
if (!WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) return@runCatching
updateAllWidgets(context)
}
}
@@ -0,0 +1,234 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.WidgetProgressRing
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.glance.R as GlanceR
import kotlinx.coroutines.flow.first
/**
* "Today" home-screen widget: how far through today you are, as a ring —
* done ÷ (done + still open today/overdue) — with today's open tasks listed
* below (or beside, when wide) once the widget has room for them.
*/
class TodayWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact, so the ring can grow with the real placement rather than a breakpoint.
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
provideContent {
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY), WidgetKind.TODAY)
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
FloretGlanceTheme(
lightScheme = AgendulaLightFallback,
darkScheme = AgendulaDarkFallback,
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
) {
TodayWidgetBody(data, background)
}
}
}
}
@Composable
private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
val context = LocalContext.current
val ready = data as? WidgetStatsData.Ready
val size = LocalSize.current
val contentWidth = size.width - Padding * 2
val contentHeight = size.height - Padding * 2 - HeaderHeight
WidgetSurface(background = background) {
Column(modifier = GlanceModifier.fillMaxSize().padding(Padding)) {
WidgetHeader(
title = context.getString(R.string.smart_today),
titleAction = actionStartActivity(MainActivity.todayIntent(context)),
) {
WidgetHeaderAction(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context)),
)
}
Spacer(GlanceModifier.height(HeaderGap))
when {
data == null -> Unit
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
else -> {
val tasks = ready.snapshot.todayTasks
val openToday = actionStartActivity(MainActivity.todayIntent(context))
when {
tasks.isNotEmpty() && contentWidth >= MinSideBySideWidth -> {
val ring = minOf(contentHeight, contentWidth * 0.4f, MaxRing)
Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = GlanceModifier.width(ring).fillMaxHeight().clickable(openToday),
contentAlignment = Alignment.Center,
) {
TodayRing(ready, ring)
}
Spacer(GlanceModifier.width(12.dp))
TodayTaskList(ready, GlanceModifier.defaultWeight().fillMaxHeight())
}
}
tasks.isNotEmpty() && contentHeight >= MinRing + ListGap + RowHeight -> {
val ring = minOf(contentWidth, MaxRing, maxOf(MinRing, contentHeight - ListGap - RowHeight * 2))
Box(
modifier = GlanceModifier.fillMaxWidth().height(ring).clickable(openToday),
contentAlignment = Alignment.Center,
) {
TodayRing(ready, ring)
}
Spacer(GlanceModifier.height(ListGap))
TodayTaskList(ready, GlanceModifier.fillMaxWidth().defaultWeight())
}
else -> {
val ring = minOf(contentWidth, contentHeight, MaxRing)
Box(
modifier = GlanceModifier.fillMaxSize().clickable(openToday),
contentAlignment = Alignment.Center,
) {
TodayRing(ready, ring)
}
}
}
}
}
}
}
}
@Composable
private fun TodayTaskList(ready: WidgetStatsData.Ready, modifier: GlanceModifier) {
val tasks = ready.snapshot.todayTasks
LazyColumn(modifier = modifier) {
items(tasks.size) { index ->
// A LazyColumn item's host view isn't a linear layout on its own,
// so the row and its gap need an explicit Column to stack.
Column {
AgendaTaskRow(
row = tasks[index],
use24Hour = ready.snapshot.use24Hour,
color = GlanceTheme.colors.surfaceVariant,
colored = false,
suppressDayLabel = true,
)
if (index < tasks.lastIndex) Spacer(GlanceModifier.height(6.dp))
}
}
}
}
@Composable
private fun TodayRing(ready: WidgetStatsData.Ready, ringSize: Dp) {
val context = LocalContext.current
val snapshot = ready.snapshot
val total = snapshot.todayDone + snapshot.todayRemaining
val allDone = total > 0 && snapshot.todayRemaining == 0
val progress = if (total == 0) 0f else snapshot.todayDone.toFloat() / total
val indicator = if (allDone) GlanceTheme.colors.tertiary else GlanceTheme.colors.primary
// Label sizes scale with the ring so a large ring doesn't hold a tiny label.
val scale = (ringSize / MinRing).coerceIn(1f, 2f)
WidgetProgressRing(
progress = progress,
size = ringSize,
trackColor = GlanceTheme.colors.surfaceVariant,
indicatorColor = indicator,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = GlanceModifier.padding(horizontal = ringSize * 0.15f)) {
if (total == 0) {
Text(
text = context.getString(R.string.widget_today_nothing_due),
maxLines = 2,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = (11 * scale).sp,
textAlign = TextAlign.Center,
),
)
} else if (allDone) {
Text(
text = context.getString(R.string.widget_today_all_done),
maxLines = 2,
style = TextStyle(
color = GlanceTheme.colors.tertiary,
fontSize = (13 * scale).sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
),
)
} else {
Text(
text = "${snapshot.todayDone}/$total",
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = (18 * scale).sp,
fontWeight = FontWeight.Medium,
),
)
}
}
}
}
private val Padding = 8.dp
private val HeaderHeight = 36.dp
private val HeaderGap = 4.dp
private val ListGap = 8.dp
private val RowHeight = 48.dp
private val MinRing = 72.dp
private val MaxRing = 160.dp
private val MinSideBySideWidth = 230.dp
@@ -0,0 +1,27 @@
package de.jeanlucmakiola.agendula.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
/** Host-facing receiver for [TodayWidget]; also keeps the midnight rollover armed. */
class TodayWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
}
@@ -0,0 +1,410 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.Action
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.color.ColorProvider
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextDecoration
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetActionButton
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.WidgetShape
import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.floret.glance.R as GlanceR
import kotlinx.coroutines.flow.first
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.time.toJavaInstant
/**
* "Up next" home-screen widget: the single open task with the nearest due
* moment, big, with a check to complete it in place — a countdown rather
* than a list, for someone who wants to see one thing at a time. Optionally
* narrowed to one list; when tall, the tasks after it are listed below.
*/
class UpNextWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
provideContent {
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY), WidgetKind.UP_NEXT)
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val listId = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))?.singleOrNull()
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
FloretGlanceTheme(
lightScheme = AgendulaLightFallback,
darkScheme = AgendulaDarkFallback,
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
) {
UpNextWidgetBody(data, background, listId)
}
}
}
}
@Composable
private fun UpNextWidgetBody(data: WidgetStatsData?, background: WidgetBackground, listId: Long?) {
val context = LocalContext.current
val ready = data as? WidgetStatsData.Ready
val size = LocalSize.current
val rows = ready?.snapshot?.upcoming?.let { all -> listId?.let { id -> all.filter { it.listId == id } } ?: all }
val list = listId?.let { id -> ready?.snapshot?.lists?.firstOrNull { it.id == id } }
WidgetSurface(background = background) {
when {
data == null -> Unit
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
rows.isNullOrEmpty() -> UpNextEmpty(listId)
size.height >= MinTallHeight -> UpNextTall(rows, ready.snapshot, list, size.height)
else -> UpNextCompact(rows.first(), ready.snapshot, size.width, size.height)
}
}
}
@Composable
private fun UpNextEmpty(listId: Long?) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxSize().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
WidgetMessage(context.getString(R.string.widget_up_next_empty), GlanceModifier.defaultWeight())
Spacer(GlanceModifier.width(8.dp))
WidgetActionButton(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context, listId)),
style = WidgetActionStyle.FILLED,
size = 36.dp,
)
}
}
/** One row tall: a cookie-shaped check leading, then the time as the hero and the title under it. */
@Composable
private fun UpNextCompact(row: WidgetTaskRow, snapshot: WidgetStatsSnapshot, width: Dp, height: Dp) {
val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val now = Clock.System.now()
val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary
val checkSize = (height * 0.45f).coerceIn(32.dp, 44.dp)
val heroSize = (height.value * 0.25f).coerceIn(16f, 22f)
Row(
modifier = GlanceModifier.fillMaxSize().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CompleteButton(row, title, checkSize)
Spacer(GlanceModifier.width(12.dp))
Column(modifier = GlanceModifier.defaultWeight().clickable(openTask(context, row))) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = upNextHero(context, row, snapshot.use24Hour),
maxLines = 1,
style = TextStyle(color = accent, fontSize = heroSize.sp, fontWeight = FontWeight.Bold),
)
if (width >= MinRelativeInlineWidth) {
Spacer(GlanceModifier.width(8.dp))
RowPill(row, upNextLabel(context, row, now))
}
}
Text(
text = title,
maxLines = if (height >= 88.dp) 2 else 1,
style = titleStyle(row, 15f),
)
}
}
}
/**
* Tall: the list as a caption with the add pill, then a big cookie check
* beside a display-size time and the title, and — with room to spare —
* what comes after it.
*/
@Composable
private fun UpNextTall(rows: List<WidgetTaskRow>, snapshot: WidgetStatsSnapshot, list: ListTile?, height: Dp) {
val context = LocalContext.current
val row = rows.first()
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val now = Clock.System.now()
val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary
val heroSize = (height.value * 0.15f).coerceIn(26f, 38f)
val checkSize = (height * 0.2f).coerceIn(44.dp, 56.dp)
val laterCount = ((height - MinTallHeight - 40.dp) / LaterRowHeight).toInt().coerceIn(0, minOf(rows.size - 1, MAX_LATER_ROWS))
Column(modifier = GlanceModifier.fillMaxSize().padding(12.dp)) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ListDot(row.color)
Spacer(GlanceModifier.width(6.dp))
Text(
text = list?.name ?: context.getString(R.string.widget_up_next_label),
maxLines = 1,
modifier = GlanceModifier.defaultWeight(),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Bold),
)
WidgetHeaderAction(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context, list?.id)),
)
}
Row(
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
verticalAlignment = Alignment.CenterVertically,
) {
CompleteButton(row, title, checkSize)
Spacer(GlanceModifier.width(12.dp))
Column(modifier = GlanceModifier.defaultWeight().clickable(openTask(context, row))) {
Text(
text = upNextHero(context, row, snapshot.use24Hour),
maxLines = 1,
style = TextStyle(color = accent, fontSize = heroSize.sp, fontWeight = FontWeight.Bold),
)
RowPill(row, upNextLabel(context, row, now))
Spacer(GlanceModifier.height(6.dp))
Text(
text = title,
maxLines = if (laterCount > 0) 2 else 3,
style = titleStyle(row, 16f),
)
}
}
if (laterCount > 0) {
Text(
text = context.getString(R.string.widget_up_next_then),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Bold),
)
Spacer(GlanceModifier.height(4.dp))
Column { rows.drop(1).take(laterCount).forEach { later -> LaterRow(later, snapshot.use24Hour) } }
}
}
}
/** The cookie check; while [WidgetCompletions] holds the row it bursts into a filled sunny shape. */
@Composable
private fun CompleteButton(row: WidgetTaskRow, title: String, size: Dp) {
val context = LocalContext.current
val completing = WidgetCompletions.isCompleting(row)
WidgetShapedIconButton(
shape = if (completing) WidgetShape.SUNNY else WidgetShape.COOKIE,
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_check),
contentDescription = context.getString(R.string.widget_complete_task, title),
action = completeTask(row),
color = when {
completing -> GlanceTheme.colors.tertiary
row.isOverdue -> GlanceTheme.colors.errorContainer
else -> GlanceTheme.colors.primaryContainer
},
iconTint = when {
completing -> GlanceTheme.colors.onTertiary
row.isOverdue -> GlanceTheme.colors.onErrorContainer
else -> GlanceTheme.colors.onPrimaryContainer
},
size = size,
)
}
@Composable
private fun RelativePill(text: String, overdue: Boolean, done: Boolean = false) {
val container = when {
done -> GlanceTheme.colors.tertiaryContainer
overdue -> GlanceTheme.colors.errorContainer
else -> GlanceTheme.colors.secondaryContainer
}
val content = when {
done -> GlanceTheme.colors.onTertiaryContainer
overdue -> GlanceTheme.colors.onErrorContainer
else -> GlanceTheme.colors.onSecondaryContainer
}
Box(
modifier = GlanceModifier.cornerRadius(10.dp)
.background(container)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
text = text,
maxLines = 1,
style = TextStyle(color = content, fontSize = 11.sp, fontWeight = FontWeight.Bold),
)
}
}
/** The relative pill's text, or "Done" while the row is being ticked off. */
@Composable
private fun RowPill(row: WidgetTaskRow, relative: String) {
val context = LocalContext.current
if (WidgetCompletions.isCompleting(row)) {
RelativePill(context.getString(R.string.reminder_action_done), row.isOverdue, done = true)
} else if (relative.isNotEmpty()) {
RelativePill(relative, row.isOverdue)
}
}
@Composable
private fun titleStyle(row: WidgetTaskRow, fontSize: Float): TextStyle {
val completing = WidgetCompletions.isCompleting(row)
return TextStyle(
color = if (completing) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface,
fontSize = fontSize.sp,
fontWeight = FontWeight.Medium,
textDecoration = if (completing) TextDecoration.LineThrough else TextDecoration.None,
)
}
@Composable
private fun LaterRow(row: WidgetTaskRow, use24Hour: Boolean) {
val context = LocalContext.current
Row(
modifier = GlanceModifier.fillMaxWidth().height(LaterRowHeight).clickable(openTask(context, row)),
verticalAlignment = Alignment.CenterVertically,
) {
ListDot(row.color)
Spacer(GlanceModifier.width(8.dp))
Text(
text = row.title.ifBlank { context.getString(R.string.task_untitled) },
maxLines = 1,
modifier = GlanceModifier.defaultWeight(),
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp),
)
Text(
text = upNextHero(context, row, use24Hour),
maxLines = 1,
style = TextStyle(
color = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp,
),
)
}
}
@Composable
private fun ListDot(color: Int) {
Box(
modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp)
.background(ColorProvider(day = pastelize(color, false), night = pastelize(color, true))),
) {}
}
private fun openTask(context: Context, row: WidgetTaskRow): Action =
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()))
private fun completeTask(row: WidgetTaskRow): Action =
actionRunCallback<CompleteTaskAction>(
actionParametersOf(
CompleteTaskAction.TaskIdKey to row.taskId,
CompleteTaskAction.OccurrenceKey to (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE),
),
)
/**
* The big line: a timed task's clock time (with its weekday once it's past
* tomorrow), or an all-day task's day.
*/
internal fun upNextHero(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String {
val due = row.due ?: return ""
val locale = context.resources.configuration.locales[0]
val day = DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "EEEd"), locale)
.format(due.toJavaInstant().atZone(ZoneId.systemDefault()))
if (row.isAllDay) {
return when (row.dueKind) {
DueKind.TODAY -> context.getString(R.string.smart_today)
DueKind.TOMORROW -> context.getString(R.string.widget_up_next_tomorrow)
else -> day
}
}
val time = due.formatTime(use24Hour)
return if (row.dueKind == DueKind.LATER && !row.isOverdue) "$day $time" else time
}
/**
* The relative line beside [upNextHero]: "in 3 h", "Tomorrow", "2 d overdue".
* Coarse on purpose — this widget only redraws on data changes, at midnight,
* and on the half-hourly `updatePeriodMillis`, so a minute-level countdown
* would just look wrong between redraws.
*/
internal fun upNextLabel(context: Context, row: WidgetTaskRow, now: Instant): String {
val due = row.due ?: return ""
if (row.isAllDay && !row.isOverdue) return if (row.dueKind == DueKind.LATER) daysUntil(context, due, now) else ""
if (row.isOverdue) {
val hours = (now - due).inWholeHours
return when {
hours < 1 -> context.getString(R.string.widget_up_next_soon)
hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_overdue_hours, hours.toInt(), hours.toInt())
else -> {
val days = hours / HOURS_PER_DAY
context.resources.getQuantityString(R.plurals.widget_up_next_overdue_days, days.toInt(), days.toInt())
}
}
}
if (row.dueKind == DueKind.TOMORROW) return context.getString(R.string.widget_up_next_tomorrow)
val hours = (due - now).inWholeHours
return when {
hours < 1 -> context.getString(R.string.widget_up_next_soon)
hours < HOURS_PER_DAY -> context.resources.getQuantityString(R.plurals.widget_up_next_in_hours, hours.toInt(), hours.toInt())
else -> daysUntil(context, due, now)
}
}
private fun daysUntil(context: Context, due: Instant, now: Instant): String {
val days = ((due - now).inWholeHours / HOURS_PER_DAY).toInt().coerceAtLeast(1)
return context.resources.getQuantityString(R.plurals.widget_up_next_in_days, days, days)
}
private const val HOURS_PER_DAY = 24L
private const val MAX_LATER_ROWS = 10
private val MinTallHeight = 150.dp
private val MinRelativeInlineWidth = 200.dp
private val LaterRowHeight = 24.dp
@@ -0,0 +1,27 @@
package de.jeanlucmakiola.agendula.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
/** Host-facing receiver for [UpNextWidget]; also keeps the midnight rollover armed. */
class UpNextWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = UpNextWidget()
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
}
@@ -0,0 +1,177 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.glance.WidgetTile
import de.jeanlucmakiola.floret.glance.R as GlanceR
import kotlinx.coroutines.flow.first
import java.time.LocalDate
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/**
* "Week" home-screen widget: a seven-day strip, one tile per day, each with
* its open-task count — the one shape none of the app's other widgets or
* screens offer (they're all "one list" or "one task" shaped).
*/
class WeekWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact, so the day count can follow the real width.
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
provideContent {
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
FloretGlanceTheme(
lightScheme = AgendulaLightFallback,
darkScheme = AgendulaDarkFallback,
dynamicColor = ready?.snapshot?.dynamicColor ?: true,
mode = ready?.let { effectiveThemeMode(it.snapshot.themeMode, themeOverride) } ?: WidgetThemeMode.SYSTEM,
) {
WeekWidgetBody(data, background)
}
}
}
}
@Composable
private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground) {
val context = LocalContext.current
val ready = data as? WidgetStatsData.Ready
WidgetSurface(background = background) {
when {
data == null -> Unit
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
else -> {
// As many days as fit at a comfortable width, up to the week.
// Gaps come from each cell's padding, not Spacers: Glance caps a
// Row at 10 children.
val days = ((LocalSize.current.width - 8.dp) / MinDayWidth).toInt().coerceIn(1, ready.snapshot.week.size)
Row(modifier = GlanceModifier.fillMaxSize().padding(4.dp)) {
ready.snapshot.week.take(days).forEach { day ->
// Glance has no margin — padding on the tile itself lands inside its fill.
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(horizontal = 2.dp)) {
WeekDayCell(day, GlanceModifier.fillMaxSize())
}
}
}
}
}
}
}
@Composable
private fun WeekDayCell(day: WeekDay, modifier: GlanceModifier) {
val context = LocalContext.current
val today = isToday(day.date)
val background = if (today) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.surfaceVariant
val onBackground = if (today) GlanceTheme.colors.onPrimaryContainer else GlanceTheme.colors.onSurfaceVariant
val open = if (today) {
actionStartActivity(MainActivity.todayIntent(context))
} else {
actionStartActivity(MainActivity.smartListIntent(context, SmartList.UPCOMING))
}
val badgeBackground = if (today) GlanceTheme.colors.primary else GlanceTheme.colors.surface
val onBadgeBackground = if (today) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurfaceVariant
WidgetTile(color = background, modifier = modifier, onClick = open) {
Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
val locale = context.resources.configuration.locales[0] ?: Locale.ROOT
Spacer(GlanceModifier.defaultWeight())
Box(
modifier = GlanceModifier
.size(38.dp)
.cornerRadius(19.dp)
.background(badgeBackground),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = day.date.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale),
maxLines = 1,
style = TextStyle(color = onBadgeBackground, fontSize = 10.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
)
Text(
text = "${day.date.dayOfMonth}",
style = TextStyle(color = onBadgeBackground, fontSize = 13.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
)
}
}
Spacer(GlanceModifier.height(3.dp))
// Always rendered, even at 0 — an omitted count row made every
// other day's badge drift up or down relative to its neighbours.
// The checklist glyph (not a single checkmark) is what reads this
// as "N tasks" rather than an unlabelled, ambiguous number.
val countColor = if (day.overdueCount > 0) GlanceTheme.colors.error else onBackground
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
provider = ImageProvider(GlanceR.drawable.floret_ic_widget_checklist),
contentDescription = null,
colorFilter = ColorFilter.tint(countColor),
modifier = GlanceModifier.size(14.dp),
)
Spacer(GlanceModifier.width(2.dp))
Text(
text = "${day.count}",
style = TextStyle(color = countColor, fontSize = 15.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
)
}
Spacer(GlanceModifier.defaultWeight())
}
}
}
private fun isToday(date: LocalDate): Boolean = date == java.time.LocalDate.now()
private val MinDayWidth = 64.dp
@@ -0,0 +1,27 @@
package de.jeanlucmakiola.agendula.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
/** Host-facing receiver for [WeekWidget]; also keeps the midnight rollover armed. */
class WeekWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = WeekWidget()
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
}
}
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.agendula.widget
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
/**
* Rows ticked in a widget but not yet completed in the store. Widgets can't
* animate, so [CompleteTaskAction] shows the row as done for a beat before
* completing it — every widget drawing that row reads this to render the
* ticked frame.
*/
internal object WidgetCompletions {
private val keys = MutableStateFlow<Set<String>>(emptySet())
fun key(taskId: Long, occurrenceStart: Long?): String = "$taskId@${occurrenceStart ?: ""}"
/** Marks [key] as completing; `false` if it already was (a double tap). */
fun start(key: String): Boolean {
var added = false
keys.update { current -> if (key in current) current else current + key.also { added = true } }
return added
}
fun finish(key: String) = keys.update { it - key }
@Composable
fun isCompleting(row: WidgetTaskRow): Boolean =
key(row.taskId, row.occurrenceStart?.toEpochMilliseconds()) in keys.collectAsState().value
}
@@ -4,12 +4,15 @@ import android.content.Context
import android.text.format.DateFormat
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.domain.comparator
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -21,19 +24,81 @@ import kotlin.time.Instant
/** Per-instance Glance state key holding the widget's [TaskFilter] ([widgetStorageValue]). */
internal val WIDGET_FILTER_KEY = stringPreferencesKey("task_filter")
/** Per-instance Glance state key: group a multi-list widget's rows under list headers. */
internal val WIDGET_GROUP_BY_LIST_KEY = booleanPreferencesKey("group_by_list")
/** Per-instance Glance state key holding the widget's [WidgetBackground] ([name]); unset = [WidgetBackground.SOLID]. */
internal val WIDGET_BACKGROUND_KEY = stringPreferencesKey("widget_background")
/** Per-instance Glance state key holding a [WidgetThemeMode] override; unset = follow the app's own theme setting. */
internal val WIDGET_THEME_KEY = stringPreferencesKey("widget_theme")
/** Per-instance Glance state key holding the widget's [WidgetRowColor] ([name]); unset = [WidgetRowColor.NEUTRAL]. */
internal val WIDGET_ROW_COLOR_KEY = stringPreferencesKey("widget_row_color")
/** Per-instance Glance state key holding the Lists widget's chosen list ids, comma-joined; unset = every visible list. */
internal val WIDGET_LIST_IDS_KEY = stringPreferencesKey("widget_list_ids")
/** `null` means "every visible list" — the Lists widget's default. */
internal fun parseWidgetListIds(raw: String?): Set<Long>? =
raw?.split(",")?.mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }?.toSet()?.takeIf { it.isNotEmpty() }
internal fun Set<Long>.widgetListIdsStorageValue(): String = joinToString(",")
internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY)
/** How a row's tonal container is tinted: the neutral surface role, or the task's own list colour. */
internal enum class WidgetRowColor { NEUTRAL, BY_LIST }
internal fun parseWidgetRowColor(raw: String?): WidgetRowColor =
raw?.let { runCatching { WidgetRowColor.valueOf(it) }.getOrNull() } ?: WidgetRowColor.NEUTRAL
internal fun parseWidgetBackground(raw: String?): WidgetBackground =
raw?.let { runCatching { WidgetBackground.valueOf(it) }.getOrNull() } ?: WidgetBackground.SOLID
/**
* [WidgetBackground]s a widget offers. Today and Up next draw their hero (ring
* label, time and title) straight on the surface rather than in containers, so
* on [WidgetBackground.TRANSPARENT] it would sit unreadable on the wallpaper.
*/
internal fun WidgetKind.backgrounds(): List<WidgetBackground> = when (this) {
WidgetKind.TODAY, WidgetKind.UP_NEXT -> WidgetBackground.entries - WidgetBackground.TRANSPARENT
else -> WidgetBackground.entries
}
/** [raw] parsed, falling back to SOLID for a stored value [kind] no longer offers. */
internal fun parseWidgetBackground(raw: String?, kind: WidgetKind): WidgetBackground =
parseWidgetBackground(raw).takeIf { it in kind.backgrounds() } ?: WidgetBackground.SOLID
/** `null` means "follow the app's own theme setting" — resolved by the caller against [TaskWidgetData.Ready.themeMode]. */
internal fun parseWidgetThemeOverride(raw: String?): WidgetThemeMode? =
raw?.let { runCatching { WidgetThemeMode.valueOf(it) }.getOrNull() }
internal fun TaskWidgetData.Ready.effectiveThemeMode(override: WidgetThemeMode?): WidgetThemeMode =
effectiveThemeMode(themeMode, override)
/** [override], or [appThemeMode] translated to its Glance counterpart — shared by every widget's data type. */
internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMode?): WidgetThemeMode =
override ?: when (appThemeMode) {
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
ThemeMode.DARK -> WidgetThemeMode.DARK
}
/** The smart lists a widget can show, in picker order. */
internal val WIDGET_SMART_LISTS = listOf(SmartList.TODAY, SmartList.UPCOMING, SmartList.OVERDUE, SmartList.ALL)
/**
* Upper bound on rows, so the serialized RemoteViews stays well inside the
* binder transaction limit however long the list is.
* binder transaction limit however long the list is. Lower than the old flat
* list's 60: each row is now wrapped in its own tonal container, so the
* per-row view count is higher for the same visual density (calendula#214).
*/
internal const val MAX_WIDGET_ROWS = 60
internal const val MAX_WIDGET_ROWS = 40
internal fun TaskFilter.widgetStorageValue(): String = when (this) {
is TaskFilter.OfList -> "list:$listId"
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
is TaskFilter.Smart -> "smart:${list.name}"
}
@@ -43,6 +108,9 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter {
return when {
value.startsWith("list:") ->
value.removePrefix("list:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.OfList(it) }
value.startsWith("lists:") ->
value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }
.toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) }
value.startsWith("smart:") ->
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
else -> null
@@ -69,6 +137,7 @@ internal fun isOverdue(due: Instant, allDay: Boolean, now: Instant, today: Local
data class WidgetTaskRow(
val taskId: Long,
val listId: Long,
val occurrenceStart: Instant?,
val title: String,
val due: Instant?,
@@ -84,52 +153,121 @@ sealed interface TaskWidgetData {
data class Ready(
val filter: TaskFilter,
/** The real list's name; `null` for a smart list or a list that no longer exists. */
val listName: String?,
/** Names of the real lists [rows] can belong to, by list id. */
val listNames: Map<Long, String>,
val rows: List<WidgetTaskRow>,
/** Group [rows] under a header per list (only meaningful with several lists). */
val groupByList: Boolean,
val dynamicColor: Boolean,
val themeMode: ThemeMode,
val use24Hour: Boolean,
) : TaskWidgetData
) : TaskWidgetData {
/** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */
val singleListId: Long? = when (filter) {
is TaskFilter.OfList -> filter.listId
is TaskFilter.OfLists -> filter.listIds.singleOrNull()
is TaskFilter.Smart -> null
}
/** [singleListId]'s name. */
val singleListName: String? = singleListId?.let(listNames::get)
val overdueCount: Int = rows.count { it.isOverdue }
}
}
/** A run of rows in the widget's list: one task, or a run's header. */
internal sealed interface WidgetSection {
data class Header(val key: String, val name: String) : WidgetSection
data class TaskItem(val row: WidgetTaskRow) : WidgetSection
}
/** One headed run: its header's stable [key] and display [name], and the rows under it. */
private data class WidgetRun(val key: String, val name: String, val rows: List<WidgetTaskRow>)
/** Each [WidgetRun] as its own [WidgetSection.Header] followed by its rows. */
private fun headedRuns(runs: List<WidgetRun>): List<WidgetSection> =
runs.flatMap { run -> listOf(WidgetSection.Header(run.key, run.name)) + run.rows.map(WidgetSection::TaskItem) }
/**
* [rows] split into headed runs: one per list when [groupByList] is on
* (already sorted that way by [widgetRows]), otherwise one per [DueKind]
* bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that
* fixed order, since [rows] isn't due-sorted. Each run becomes one header
* followed by its own standalone, independently coloured task pills.
*/
internal fun widgetSections(
rows: List<WidgetTaskRow>,
groupByList: Boolean,
listNames: Map<Long, String>,
dueBucketLabel: (DueKind?) -> String,
): List<WidgetSection> {
if (groupByList) {
val runs = mutableListOf<WidgetRun>()
var current: MutableList<WidgetTaskRow>? = null
for (row in rows) {
if (current == null || runs.last().key != "list:${row.listId}") {
current = mutableListOf()
runs += WidgetRun("list:${row.listId}", listNames[row.listId].orEmpty(), current)
}
current.add(row)
}
return headedRuns(runs)
}
val buckets = rows.groupBy { it.dueKind }
val runs = DueKind.entries.plus(null).mapNotNull { kind ->
buckets[kind]?.takeIf { it.isNotEmpty() }
?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) }
}
return headedRuns(runs)
}
/**
* Open rows for [tasks], sorted the way the app sorts them. A subtask whose
* parent is also shown is left out, as on the list screen.
* Open rows for [tasks], sorted the way the app sorts them, then grouped by list
* when [groupByList] is on (stable, so each list's own order survives). A
* subtask whose parent is also shown is left out, as on the list screen.
*/
internal fun widgetRows(
tasks: List<Task>,
comparator: Comparator<Task>,
now: Instant,
zone: ZoneId,
groupByList: Boolean,
listNames: Map<Long, String>,
): List<WidgetTaskRow> {
val open = tasks.filterNot { it.isClosed }
val present = open.mapTo(HashSet()) { it.taskId }
val today = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
return open
val sorted = open
.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
.sortedWith(comparator)
.take(MAX_WIDGET_ROWS)
.map { task ->
WidgetTaskRow(
taskId = task.taskId,
occurrenceStart = task.occurrenceStart,
title = task.title,
due = task.due,
isAllDay = task.isAllDay,
dueKind = task.due?.let { dueKind(it, task.isAllDay, today, zone) },
isOverdue = task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } ?: false,
color = task.effectiveColor,
)
.let {
if (groupByList) {
it.sortedWith(compareBy({ task -> listNames[task.listId].orEmpty() }, { task -> task.listId }))
} else it
}
return sorted.take(MAX_WIDGET_ROWS).map { it.toWidgetRow(now, today, zone) }
}
/** This task as a [WidgetTaskRow], resolving its due bucket and overdue state against [today]. */
internal fun Task.toWidgetRow(now: Instant, today: LocalDate, zone: ZoneId): WidgetTaskRow = WidgetTaskRow(
taskId = taskId,
listId = listId,
occurrenceStart = occurrenceStart,
title = title,
due = due,
isAllDay = isAllDay,
dueKind = due?.let { dueKind(it, isAllDay, today, zone) },
isOverdue = due?.let { isOverdue(it, isAllDay, now, today, zone) } ?: false,
color = effectiveColor,
)
/**
* Live widget data for [filter]. Re-emits on every store change, since the
* repository's flows observe the store; a store that can't be read (permission
* revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable].
*/
internal fun Context.taskWidgetData(filter: TaskFilter): Flow<TaskWidgetData> {
internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): Flow<TaskWidgetData> {
val ep = widgetEntryPoint()
val repository = ep.tasksRepository()
return combine(
@@ -137,10 +275,19 @@ internal fun Context.taskWidgetData(filter: TaskFilter): Flow<TaskWidgetData> {
repository.taskLists(),
ep.settingsPrefs().settings,
) { tasks, lists, settings ->
val listNames = lists.associate { it.id to it.name }
val data: TaskWidgetData = TaskWidgetData.Ready(
filter = filter,
listName = (filter as? TaskFilter.OfList)?.let { f -> lists.firstOrNull { it.id == f.listId }?.name },
rows = widgetRows(settings.visibleIn(filter, tasks), settings.sortOrder.comparator(), Clock.System.now(), ZoneId.systemDefault()),
listNames = listNames,
rows = widgetRows(
settings.visibleIn(filter, tasks),
settings.sortOrder.comparator(),
Clock.System.now(),
ZoneId.systemDefault(),
groupByList,
listNames,
),
groupByList = groupByList,
dynamicColor = settings.dynamicColor,
themeMode = settings.themeMode,
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
@@ -1,83 +0,0 @@
package de.jeanlucmakiola.agendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.getSystemService
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* The app's own wake-up for the next local midnight, so "Today" on the widget
* flips over on the day boundary. `ACTION_DATE_CHANGED` isn't delivered to
* manifest receivers since Android 8, which is why this exists.
*
* One inexact alarm at a time, re-armed by every firing: `setAndAllowWhileIdle`
* needs no permission and survives doze, and a rollover a few minutes late is
* invisible on a sleeping screen.
*/
object WidgetRolloverScheduler {
/** Fire just after midnight, so an early delivery doesn't still read the old date. */
internal val ROLLOVER_SLACK = 5.seconds
/** Arm the next rollover, or cancel it when no widget is placed. Idempotent. */
fun sync(context: Context) {
val appContext = context.applicationContext
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
val pendingIntent = rolloverPendingIntent(appContext)
if (!hasPlacedWidgets(appContext)) {
alarmManager.cancel(pendingIntent)
return
}
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
)
}
/**
* The instant just after the next local midnight following [now] in [zone].
* Uses the actual start of day, so it holds where DST skips midnight or a
* date-line move skips a whole date.
*/
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date
var days = 1
while (days <= MAX_LOOKAHEAD_DAYS) {
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
if (candidate > now) return candidate
days++
}
// Unreachable for any real zone; an hour rather than the slack so it can't loop.
return now + 1.hours
}
fun hasPlacedWidgets(context: Context): Boolean {
val manager = AppWidgetManager.getInstance(context) ?: return false
return manager.getAppWidgetIds(ComponentName(context, TaskWidgetReceiver::class.java)).isNotEmpty()
}
private fun rolloverPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java)
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
private const val MAX_LOOKAHEAD_DAYS = 3
}
@@ -0,0 +1,152 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
import android.text.format.DateFormat
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.domain.comparator
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Clock
import kotlin.time.Instant
/** One day of the Week widget's strip. [first] is that day's earliest task, in sort order. */
internal data class WeekDay(val date: LocalDate, val count: Int, val overdueCount: Int, val first: WidgetTaskRow?)
/** One tile of the Lists widget. */
internal data class ListTile(val id: Long, val name: String, val color: Int, val openCount: Int, val acceptsWrites: Boolean)
internal data class WidgetStatsSnapshot(
val todayDone: Int,
val todayRemaining: Int,
val todayTasks: List<WidgetTaskRow>,
/** Open dated tasks, earliest due first — Up next shows the head, optionally narrowed to one list. */
val upcoming: List<WidgetTaskRow>,
val week: List<WeekDay>,
val lists: List<ListTile>,
val dynamicColor: Boolean,
val themeMode: ThemeMode,
val use24Hour: Boolean,
)
internal sealed interface WidgetStatsData {
/** No readable store yet, mirroring [TaskWidgetData.Unavailable]. */
data object Unavailable : WidgetStatsData
data class Ready(val snapshot: WidgetStatsSnapshot) : WidgetStatsData
}
private const val MAX_TODAY_ROWS = 20
/**
* How far through today a set of [open] and [completed] tasks says you are:
* tasks done today (by [Task.completedAt]'s local date), tasks still open that
* are overdue or due today (in [comparator] order), and those tasks as rows.
*/
internal fun todayProgress(
open: List<Task>,
completed: List<Task>,
comparator: Comparator<Task>,
now: Instant,
today: LocalDate,
zone: ZoneId,
): Triple<Int, Int, List<WidgetTaskRow>> {
val done = completed.count { task ->
val at = task.completedAt ?: return@count false
at.toLocalDate(zone) == today
}
val remaining = open.filter { task ->
val due = task.due ?: return@filter false
isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY
}.sortedWith(comparator)
return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) })
}
/** Open dated tasks by earliest due moment, overdue included; undated tasks are skipped. */
internal fun upcoming(open: List<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WidgetTaskRow> =
open.filter { it.due != null }.sortedBy { it.due!! }.take(MAX_UPCOMING_ROWS).map { it.toWidgetRow(now, today, zone) }
private const val MAX_UPCOMING_ROWS = 200
/**
* Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue
* task regardless of its own due day, since that's where the app's own
* Overdue/Today smart lists put it; every other day matches its own due date.
*/
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> =
(0..6L).map { offset ->
val date = today.plusDays(offset)
val dayTasks = open.filter { task ->
val due = task.due ?: return@filter false
if (offset == 0L) {
isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date
} else {
due.calendarDate(task.isAllDay, zone) == date
}
}
val overdueCount = if (offset == 0L) {
dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) }
} else {
0
}
val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone)
WeekDay(date, dayTasks.size, overdueCount, first)
}
/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */
internal fun listTiles(open: List<Task>, lists: List<TaskList>): List<ListTile> {
val present = open.mapTo(HashSet()) { it.taskId }
val counted = open.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
val counts = counted.groupingBy { it.listId }.eachCount()
return lists.filter { it.isVisible }.map { list ->
ListTile(list.id, list.name, list.color, counts[list.id] ?: 0, list.acceptsWrites)
}
}
private fun Instant.toLocalDate(zone: ZoneId): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
/**
* Live stats for the Today, Up next, Lists and Week widgets — the ALL and
* COMPLETED smart lists, filtered the same way the app's own smart lists are
* ([de.jeanlucmakiola.agendula.data.prefs.Settings.visibleIn]).
*/
internal fun Context.widgetStats(): Flow<WidgetStatsData> {
val ep = widgetEntryPoint()
val repository = ep.tasksRepository()
return combine(
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
repository.taskLists(),
ep.settingsPrefs().settings,
) { openRaw, completedRaw, lists, settings ->
val open = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), openRaw)
val completed = settings.visibleIn(TaskFilter.Smart(SmartList.ALL), completedRaw)
val now = Clock.System.now()
val zone = ZoneId.systemDefault()
val today = now.toLocalDate(zone)
val comparator = settings.sortOrder.comparator()
val (done, remaining, todayTasks) = todayProgress(open, completed, comparator, now, today, zone)
val data: WidgetStatsData = WidgetStatsData.Ready(
WidgetStatsSnapshot(
todayDone = done,
todayRemaining = remaining,
todayTasks = todayTasks,
upcoming = upcoming(open, now, today, zone),
week = weekDays(open, comparator, now, today, zone),
lists = listTiles(open, lists),
dynamicColor = settings.dynamicColor,
themeMode = settings.themeMode,
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
),
)
data
}.catch { emit(WidgetStatsData.Unavailable) }
}
@@ -1,39 +0,0 @@
package de.jeanlucmakiola.agendula.widget
import android.os.Build
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.material3.ColorProviders
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback
import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback
private val AgendulaGlanceColors = ColorProviders(
light = AgendulaLightFallback,
dark = AgendulaDarkFallback,
)
/**
* Glance counterpart of `AgendulaTheme`: Material You on API 31+ when dynamic
* colour is on, the brand fallback schemes otherwise. On [ThemeMode.SYSTEM] it
* draws only from colour-role tokens, so light/dark follows the system without a
* redraw; a forced light or dark theme pins one scheme for both.
*/
@Composable
fun AgendulaGlanceTheme(dynamicColor: Boolean, themeMode: ThemeMode, content: @Composable () -> Unit) {
val dynamic = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val context = LocalContext.current
val colors = when (themeMode) {
ThemeMode.SYSTEM -> if (dynamic) GlanceTheme.colors else AgendulaGlanceColors
ThemeMode.LIGHT -> ColorProviders(
if (dynamic) dynamicLightColorScheme(context) else AgendulaLightFallback,
)
ThemeMode.DARK -> ColorProviders(
if (dynamic) dynamicDarkColorScheme(context) else AgendulaDarkFallback,
)
}
GlanceTheme(colors = colors, content = content)
}
@@ -1,56 +1,54 @@
package de.jeanlucmakiola.agendula.widget
import android.content.BroadcastReceiver
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.updateAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
import de.jeanlucmakiola.floret.glance.WidgetRollover
/** Every home-screen widget's receiver class — the set [WidgetRollover] checks for placed instances. */
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = listOf(
TaskWidgetReceiver::class.java,
TodayWidgetReceiver::class.java,
UpNextWidgetReceiver::class.java,
ListsWidgetReceiver::class.java,
WeekWidgetReceiver::class.java,
)
/** Redraws every placed home-screen widget. */
internal suspend fun updateAllWidgets(context: Context) {
TaskWidget().updateAll(context)
TodayWidget().updateAll(context)
UpNextWidget().updateAll(context)
ListsWidget().updateAll(context)
WeekWidget().updateAll(context)
}
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER"
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/**
* Redraws the task widget when its day or its language goes stale, and keeps the
* midnight rollover armed. Task data changes don't come through here — see
* Redraws every widget when its day or language goes stale, and keeps the
* midnight rollover armed — the app-specific glue around
* [FloretWidgetUpdateReceiver]. Task data changes don't come through here — see
* [TaskWidgetUpdater].
*
* Exported for the system broadcasts; an extra redraw from another app is harmless.
*/
class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext
val rearm = intent.action in REARM_ACTIONS
// The host redraws the widget itself after these two.
val redraw = intent.action !in REARM_ONLY_ACTIONS
val pending = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (rearm) WidgetRolloverScheduler.sync(appContext)
if (redraw && WidgetRolloverScheduler.hasPlacedWidgets(appContext)) {
TaskWidget().updateAll(appContext)
}
} finally {
pending.finish()
}
}
}
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
override val rolloverAction = ACTION_WIDGET_ROLLOVER
override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
companion object {
const val ACTION_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER"
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_LOCALE_CHANGED
override suspend fun redraw(context: Context) {
updateAllWidgets(context)
}
}
@@ -1,6 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>
@@ -1,6 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock of WidgetProgressRing for the widget-picker preview. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke android:width="7dp" android:color="@color/widget_preview_primary" />
</shape>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout); the live
widget is rendered by Glance. -->
<GridLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:padding="8dp"
android:columnCount="2"
android:rowCount="2"
tools:ignore="HardcodedText">
<TextView
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_columnWeight="1"
android:layout_rowWeight="1"
android:layout_margin="4dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="#B48A9C"
android:padding="8dp"
android:gravity="bottom"
android:text="Home\n3"
android:textColor="@android:color/white"
android:textSize="13sp" />
<TextView
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_columnWeight="1"
android:layout_rowWeight="1"
android:layout_margin="4dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="#7FA3C9"
android:padding="8dp"
android:gravity="bottom"
android:text="Work\n5"
android:textColor="@android:color/white"
android:textSize="13sp" />
</GridLayout>
@@ -30,7 +30,7 @@
android:padding="9dp"
android:contentDescription="@null"
android:background="@drawable/preview_add_bg"
android:src="@drawable/ic_widget_add"
android:src="@drawable/floret_ic_widget_add"
android:tint="@color/widget_preview_on_primary_container" />
</LinearLayout>
@@ -52,7 +52,7 @@
android:layout_marginStart="9dp"
android:layout_marginEnd="9dp"
android:contentDescription="@null"
android:src="@drawable/ic_widget_checkbox"
android:src="@drawable/floret_ic_widget_check"
android:tint="@color/widget_preview_variant" />
<LinearLayout
android:layout_width="match_parent"
@@ -91,7 +91,7 @@
android:layout_marginStart="9dp"
android:layout_marginEnd="9dp"
android:contentDescription="@null"
android:src="@drawable/ic_widget_checkbox"
android:src="@drawable/floret_ic_widget_check"
android:tint="@color/widget_preview_variant" />
<LinearLayout
android:layout_width="match_parent"
@@ -130,7 +130,7 @@
android:layout_marginStart="9dp"
android:layout_marginEnd="9dp"
android:contentDescription="@null"
android:src="@drawable/ic_widget_checkbox"
android:src="@drawable/floret_ic_widget_check"
android:tint="@color/widget_preview_variant" />
<LinearLayout
android:layout_width="match_parent"
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout); the live
widget is rendered by Glance. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/preview_widget_bg"
android:padding="10dp"
tools:ignore="HardcodedText,UseAppTint">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/smart_today"
android:textColor="@color/widget_preview_primary"
android:textSize="16sp"
android:textStyle="bold" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="4dp">
<ImageView
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center"
android:contentDescription="@null"
android:background="@drawable/preview_ring" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="4/6"
android:textColor="@color/widget_preview_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout); the live
widget is rendered by Glance. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/preview_widget_bg"
android:padding="10dp"
tools:ignore="HardcodedText,UseAppTint">
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_marginEnd="9dp"
android:contentDescription="@null"
android:src="@drawable/floret_ic_widget_check"
android:tint="@color/widget_preview_variant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Call the dentist"
android:textColor="@color/widget_preview_on_surface"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="in 3 h"
android:textColor="@color/widget_preview_variant"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout); the live
widget is rendered by Glance. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:background="@drawable/preview_widget_bg"
android:padding="6dp"
tools:ignore="HardcodedText">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp"
android:background="@color/widget_preview_primary_container"
android:gravity="center"
android:text="M\n17\n2"
android:textColor="@color/widget_preview_on_primary_container"
android:textSize="11sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp"
android:background="@color/widget_preview_surface"
android:gravity="center"
android:text="T\n18\n1"
android:textColor="@color/widget_preview_variant"
android:textSize="11sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp"
android:background="@color/widget_preview_surface"
android:gravity="center"
android:text="W\n19\n·"
android:textColor="@color/widget_preview_variant"
android:textSize="11sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="2dp"
android:background="@color/widget_preview_surface"
android:gravity="center"
android:text="T\n20\n3"
android:textColor="@color/widget_preview_variant"
android:textSize="11sp" />
</LinearLayout>
+246 -6
View File
@@ -82,7 +82,7 @@
<string name="lists_header">Listen</string>
<string name="new_task">Neue Aufgabe</string>
<string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string>
<string name="lists_empty">Keine Aufgaben-Listen bisher. Füge eine in deiner Aufgaben-App hinzu oder mit der + Schaltfläche.</string>
<string name="lists_empty">Keine Aufgaben-Listen bisher.</string>
<string name="smart_today">Heute</string>
<string name="smart_overdue">Überfällig</string>
<string name="smart_upcoming">Anstehend</string>
@@ -99,23 +99,23 @@
<string name="reminder_channel_name">Aufgaben Erinnerungen</string>
<string name="reminder_channel_desc">Benachrichtigungen für fällige Aufgaben.</string>
<string name="onboarding_no_provider_title">Eine Task-App wird benötigt</string>
<string name="onboarding_no_provider_body">Agendula zeigt und bearbeitet die Aufgaben, die in OpenTasks oder tasks.org gespeichert sind und per DAVx5 synchronisiert werden. Installiere eine davon, um zu starten.</string>
<string name="onboarding_no_provider_body">Es ist keine App installiert, in der man Aufgaben speichern könnte. Installieren OpenTasks oder tasks.org oder wechsel zu Agendulas eigenem Speicher.</string>
<string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
<string name="onboarding_permission_button">Zugriff gewähren</string>
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
<string name="reminder_onboarding_body">Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist.</string>
<string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
<string name="reminder_benefit_delivery_body">Es plant eine Benachrichtigung für jede Aufgabe mit einem Fälligkeitsdatum.</string>
<string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string>
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
<string name="reminder_benefit_timing_body">Wähle in den Einstellungen, wie weit im Voraus du erinnert werden möchtest.</string>
<string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string>
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
<string name="reminder_benefit_reversible_body">Schalte Erinnerungen aus, wann immer du willst — es ist nur ein Schalter.</string>
<string name="settings_title">Einstellungen</string>
<string name="settings_section_appearance">Darstellung</string>
<string name="settings_appearance_subtitle">Theme und Farbe</string>
<string name="settings_appearance_subtitle">Theme, Farbe und Zeitformat</string>
<string name="settings_section_task_form">Aufgaben-Formular</string>
<string name="settings_task_form_subtitle">Standard Felder, Liste und Unter-Aufgaben</string>
<string name="settings_reminders_subtitle">Benachrichtigungen für fällige Aufgaben</string>
@@ -169,4 +169,244 @@
</plurals>
<string name="settings_translate">Hilf beim Übersetzen</string>
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
<string name="task_completed">Fertig</string>
<string name="cd_repeats">Wiederholt</string>
<string name="shortcut_new_task">Neue Aufgabe</string>
<string name="shortcut_today">Heute</string>
<string name="qs_tile_new_task">Neue Aufgabe</string>
<string name="lists_reorder">Liste umsortieren</string>
<string name="lists_reorder_done">Ferig umsortiert</string>
<string name="lists_reorder_handle">Ziehen zum Verschieben</string>
<string name="sort_title">Sortieren nach</string>
<string name="sort_due">Fälligkeitsdatum</string>
<string name="sort_priority">Priorität</string>
<string name="sort_title_alpha">Titel</string>
<string name="sort_created">Kürzlich hinzugefügt</string>
<plurals name="clear_completed_body">
<item quantity="one">%1$d erledigte Aufgabe in dieser Liste wird gelöscht.</item>
<item quantity="other">%1$d erledigte Aufgaben in dieser Liste werden gelöscht.</item>
</plurals>
<string name="clear_completed_confirm">Löschen</string>
<string name="settings_list_notifications">Benachrichtigungen</string>
<string name="settings_list_notifications_hint">Töne, Vibration und Wichtigkeit für Erinnerungen der Liste</string>
<string name="recurrence_generic">Wiederholende Aufgabe</string>
<string name="recurrence_daily">Jeden Tag</string>
<string name="recurrence_weekly">Jede Woche</string>
<string name="recurrence_monthly">Jedem Monat</string>
<string name="recurrence_yearly">Jedes Jahr</string>
<plurals name="recurrence_every_n_days">
<item quantity="one">Jeden Tag</item>
<item quantity="other">Alle %1$d Tage</item>
</plurals>
<plurals name="recurrence_every_n_weeks">
<item quantity="one">Jede Woche</item>
<item quantity="other">Alle %1$d Wochen</item>
</plurals>
<plurals name="recurrence_every_n_months">
<item quantity="one">Jeden Monat</item>
<item quantity="other">Allw %1$d Monate</item>
</plurals>
<plurals name="recurrence_every_n_years">
<item quantity="one">Jedes Jahr</item>
<item quantity="other">Alle %1$d Jahre</item>
</plurals>
<string name="recurrence_on_days">%1$s am %2$s</string>
<string name="recurrence_with_until">%1$s bis %2$s</string>
<string name="recurrence_none">Wiederholt sich nicht</string>
<string name="recurrence_custom">Benutzerdefiniert</string>
<string name="recurrence_unit_days">Tage</string>
<string name="recurrence_unit_weeks">Wochen</string>
<string name="recurrence_unit_months">Monate</string>
<string name="recurrence_unit_years">Jahre</string>
<string name="recurrence_incomplete">Gebe eine Nummer zwischen 1 und 999 ein</string>
<string name="recurrence_end_never">Nie</string>
<string name="recurring_scope_occurrence">Nur diese Aufgabe</string>
<string name="recurring_scope_following">Diese und folgende Aufgaben</string>
<string name="error_recurrence_without_date">Eine sich wiederholende Aufgabe braucht ein Start- oder Fälligkeitsdatum</string>
<string name="edit_location_label">Standort</string>
<string name="edit_url_label">Link</string>
<string name="edit_url_hint">https://…</string>
<string name="edit_discard_title">Änderungen verwerfen?</string>
<string name="edit_discard_body">Deine Änderungen an dieser Aufgabe wurden nicht gespeichert.</string>
<string name="edit_discard_confirm">Verwerfen</string>
<string name="edit_keep_editing">Weiter bearbeiten</string>
<string name="edit_reminder_add">Erinnerung hinzufügen</string>
<string name="edit_reminder_add_first">Erinnerung hinzufügen (sonst wird Standard angewendet)</string>
<string name="edit_reminder_remove">Erinnerung entfernen</string>
<string name="subtask_progress">%1$d / %2$d</string>
<string name="progress_fraction_compact">%1$d/%2$d</string>
<string name="detail_delete_title">Aufgabe löschen?</string>
<string name="detail_delete_body">Die Aufgabe wird gelöscht.</string>
<plurals name="detail_delete_body_subtasks">
<item quantity="one">Diese Aufgabe und ihre Unteraufgabe werden gelöscht.</item>
<item quantity="other">Diese Aufgabe und ihre %1$d Unteraufgaben werden gelöscht.</item>
</plurals>
<string name="detail_status_cancelled">Abgebrochen</string>
<string name="detail_status_in_process">In Bearbeitung</string>
<string name="more_options">Mehr Optionen</string>
<string name="detail_cancel_task">Aufgabe abbrechen</string>
<string name="detail_restore">Aufgabe wiederherstellen</string>
<string name="detail_duplicate">Duplizieren</string>
<string name="lists_empty_action">Liste erstellen</string>
<string name="list_add">Neue Liste</string>
<string name="list_new_title">Neue Liste</string>
<string name="list_edit_title">Liste bearbeiten</string>
<string name="list_name_hint">Listenname</string>
<string name="list_color">Farbe</string>
<string name="list_delete">Liste löschen</string>
<string name="list_delete_confirm_title">Liste löschen?</string>
<string name="list_where_device">Nur auf diesem Gerät</string>
<string name="detail_pane_empty">Wähle eine Aufgabe, um sie hier zu sehen</string>
<string name="percent_complete">%1$d%%</string>
<string name="reminder_on_the_day">Ab dem Tag</string>
<string name="recurrence_next">Als nächstes: %1$s</string>
<string name="recurrence_end_until">An einem Tag</string>
<plurals name="home_today_remaining_count">
<item quantity="one">%1$d übrig</item>
<item quantity="other">%1$d übrig</item>
</plurals>
<string name="reminder_day_today">heute</string>
<string name="reminder_day_tomorrow">morgen</string>
<string name="reminder_day_yesterday">gestern</string>
<string name="reminder_action_done">Fertig</string>
<string name="onboarding_welcome_title">Alles, was du machen wolltest</string>
<string name="onboarding_welcome_body">Ein ruhiger Ort für deine Aufgaben auf deinem Smartphone, das deins bleibt. Lass es uns einrichten, es braucht eine Minute.</string>
<string name="onboarding_welcome_button">Loslegen</string>
<string name="onboarding_welcome_lists_title">Listen und Unteraufgaben</string>
<string name="onboarding_welcome_reminders_title">Erinnerungen, die ankommen</string>
<string name="onboarding_welcome_reminders_body">Eine Benachrichtigung, wenn etwas fällig wird, nach deinem Plan</string>
<string name="onboarding_welcome_sync_body">Synchronisiere über CalDAV mit einem Server, den du wählst, oder behalte alles auf diesem Gerät.</string>
<string name="onboarding_sync_title">Mit deinem eigenen Server synchronisieren lassen?</string>
<string name="onboarding_sync_body">Agendula spricht CalDav — Nextcloud, Radicale, Posteo, Fastmail und alles, was auch CalDAV spricht. Keine Konten bei uns, nie.</string>
<string name="onboarding_sync_connect">Konto verbinden</string>
<string name="onboarding_sync_skip">Nicht jetzt</string>
<string name="onboarding_sync_devices_body">Hake etwas hier ab, und es wird Überfall abgehakt.</string>
<string name="onboarding_sync_copy_body">Deine Aufgaben leben auch auf dem Server, also ist ein verlorenes Gerät nicht verlorene Arbeit.</string>
<string name="onboarding_list_title">Deine erste Liste</string>
<string name="onboarding_list_default_name">Persönlich</string>
<string name="onboarding_list_create">Liste erstellen</string>
<string name="onboarding_backup_title">Eine Sicherung behalten</string>
<string name="onboarding_backup_continue">Fortsetzen</string>
<string name="onboarding_quick_add_title">Wie möchtest du Aufgaben hinzufügen?</string>
<string name="clear_completed_action">Löschen</string>
<string name="clear_completed_title">Erledigte Aufgaben löschen?</string>
<string name="sync_notice_retry">Wiederholen</string>
<string name="sync_notice_cause_server_newer">Durch die neuere Kopie vom Server ersetzt</string>
<string name="sync_sign_in_channel_name">Anmeldung nötig</string>
<string name="sync_sign_in_title">Bei %1$s wieder anmelden</string>
<string name="sync_notification_channel">Synchronisieren</string>
<string name="sync_notification_title">Aufgaben synchronisieren</string>
<string name="add_account_server_label">Server-Adresse</string>
<string name="add_account_server_hint">https://cloud.example.com</string>
<string name="add_account_username_label">Benutzername</string>
<string name="add_account_password_label">Passwort</string>
<string name="add_account_sign_in">Anmelden</string>
<string name="add_account_browser_title">Auf Browser warten</string>
<string name="add_account_browser_body">Bestätige Agendula im Browser, komme dann zurück hierher. Dein Passwort gelangt niemals zu dieser App, der Server vergibt ein seperates App-Passwort, das du jederzeit löschen kannst.</string>
<string name="add_account_browser_use_password">Stattdessen Passwort nutzen</string>
<string name="add_account_browser_retry">Nochmal versuchen</string>
<string name="add_account_browser_open">Öffne die Anmeldeseite</string>
<string name="add_account_browser_insecure">Diese Anmeldeseite ist unverschlüsselt (http). Alles, was du darin schreibst, auch dein Passwort, wird unverschlüsselt versendet.</string>
<string name="add_account_lists_title">Welche Listen sollen synchronisiert werden?</string>
<string name="add_account_lists_shared">Mit dir geteilt</string>
<string name="add_account_save">Konto hinzufügen</string>
<string name="add_account_no_lists_selected">Wähle mindestens eine Liste</string>
<string name="widget_tasks_label">Aufgaben</string>
<string name="widget_empty">Hier ist nichts zu tun</string>
<string name="widget_unavailable">Öffne Agendula und die Einrichtung abzuschließen</string>
<string name="widget_config_title">Im Widget zeigen</string>
<string name="widget_config_smart_lists">Intelligente Listen</string>
<string name="list_color_blue">Blau</string>
<string name="list_color_purple">Lila</string>
<string name="list_color_pink">Rosa</string>
<plurals name="home_today_progress_count">
<item quantity="one">%1$d von %2$d abgeschlossen</item>
<item quantity="other">%1$d von %2$d abgeschlossen</item>
</plurals>
<string name="settings_group_look">Aussehen und Verhalten</string>
<string name="settings_group_data">Daten</string>
<string name="settings_group_app">App</string>
<string name="settings_group_about">Über</string>
<string name="settings_autofocus_title">Fokusiere den Titel bei Erstellung neuer Aufgaben</string>
<plurals name="duration_minutes">
<item quantity="one">%d Minute</item>
<item quantity="other">%d Minuten</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d Stunde</item>
<item quantity="other">%d Stunden</item>
</plurals>
<string name="settings_section_accounts">Konten</string>
<string name="accounts_empty_title">Noch keine Konten</string>
<string name="accounts_add">Konto hinzufügen</string>
<string name="accounts_sync_interval">Automatisch synchronisieren</string>
<plurals name="accounts_sync_every_minutes">
<item quantity="one">Jede %d Minute</item>
<item quantity="other">Jede %d Minuten</item>
</plurals>
<string name="list_save_failed">Liste konnte nicht gespeichert werden.</string>
<string name="list_delete_failed">Liste konnte nicht gelöscht werden.</string>
<string name="list_save_failed_offline">Server konnte nicht erreicht werden. Versuche es bitte erneut, wenn du wieder online bist.</string>
<string name="list_save_failed_unsupported">Dieses Konto lässt Apps keine Aufgabenlisten erstellen.</string>
<string name="list_delete_confirm_message">\"%1$s\" und alle Aufgaben davon werden gelöscht. Das kann nicht rückgängig gemacht werden.</string>
<string name="list_color_red">Rot</string>
<string name="list_color_orange">Orange</string>
<string name="list_color_olive">Olive</string>
<string name="list_color_green">Grün</string>
<string name="list_color_cyan">Cyan</string>
<string name="settings_time_format">Zeitformat</string>
<string name="settings_time_format_auto">Automatisch</string>
<string name="settings_week_start">Woche startet am</string>
<string name="settings_week_start_auto">Automatisch</string>
<string name="settings_week_start_auto_summary">Momentan: %1$s</string>
<string name="settings_licences">Open-Source Lizenzen</string>
<string name="settings_licences_subtitle">Die Projekte, auf denen Agendula aufgebaut ist</string>
<string name="licences_title">Open-Source Lizenzen</string>
<string name="licences_intro">Agendula beinhaltet die unten gelistete Arbeit. Drücke eine, um ihren Quellcode zu erhalten.</string>
<string name="settings_privacy">Datenschutzbestimmungen</string>
<string name="settings_notifications_blocked">Benachrichtigungen sind deaktiviert</string>
<plurals name="import_done">
<item quantity="one">%1$d Aufgabe in %2$s importiert</item>
<item quantity="other">%1$d Aufgaben in %2$s importiert</item>
</plurals>
<plurals name="import_done_skipped_existing">
<item quantity="one">%1$d schon vorhanden. Wird übersprungen.</item>
<item quantity="other">%1$d schon vorhanden. Werden übersprungen.</item>
</plurals>
<string name="import_failed_unreadable">Die Datei konnte nicht als iCalendar eingelesen werden</string>
<string name="import_failed_no_tasks">Die Datei beinhaltet keine Aufgaben</string>
<string name="import_failed_write">Die Aufgabe konnte nicht gespeichert werden</string>
<plurals name="accounts_sync_every_hours">
<item quantity="one">Jede Stunde</item>
<item quantity="other">Alle %d Stunden</item>
</plurals>
<string name="accounts_remove">Konto entfernen</string>
<string name="accounts_generic_provider">CalDAV-Konto</string>
<string name="accounts_sync_now">Jetzt synchronisieren</string>
<string name="accounts_removing">Entfernen…</string>
<string name="accounts_lists">Listen</string>
<string name="add_account_step_of">Schritt %1$d von %2$d</string>
<string name="add_account_provider_other_summary">Baïkal, DAViCal, SOGo, Radicale oder einen, den du selber laufen lässt</string>
<string name="add_account_provider_other_header">Jeglichen CalDAV-Server</string>
<string name="add_account_provider_other">Andere CalDAV-Server</string>
<string name="add_account_email_title">Was ist dort deine Adresse?</string>
<string name="add_account_email_body">Die E-Mail-Adresse, mit der du diesen Dienst nutzt. Agendula findet den Server selbst.</string>
<string name="add_account_email_label">E-Mail-Adresse</string>
<string name="add_account_summary_title">Du bist eingerichtet</string>
<string name="add_account_summary_done">Fertig</string>
<string name="add_account_server_title">Wo sind deine Aufgaben?</string>
<string name="add_account_credentials_title">Anmelden</string>
<string name="add_account_error_not_an_address">Das scheint keine Web-Adresse zu sein. Versuche etwas wie cloud.example.com.</string>
<string name="add_account_working_signing_in">Anmelden</string>
<string name="add_account_working_reading_lists">Aufgaben auslesen</string>
<string name="add_account_working_saving">Konto hinzufügen</string>
<string name="add_account_error_google_unsupported">Google Kalendar unterstützt nach eigener Dokumentation nicht Aufgaben über CalDAV, also kann Agendula nicht damit synchronisieren.</string>
<string name="add_account_error_already_exists">Konto ist schon eingerichtet.</string>
<string name="add_account_error_credentials_rejected">Diser Benutzername oder dieses Passwort wurde nicht akzeptiert.</string>
<string name="add_account_browser_error_maintenance">Der Server befindet sich um Wartungsmodus. Versuche es erneut, wenn er wieder zurück ist.</string>
<string name="add_account_setup_icloud_title">iCloud braucht ein App-spezifisches Passwort</string>
<string name="accounts_sign_in_again">Erneut anmelden</string>
<string name="sync_notice_body">%1$s</string>
<string name="sync_notices_title">Änderungen durch letzte Synchronisierung</string>
<string name="sync_notices_dismiss">Verstanden</string>
</resources>
+62 -1
View File
@@ -430,6 +430,8 @@
<string name="settings_default_list_first">First available list</string>
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
<string name="settings_default_start_today">Default start to today</string>
<string name="settings_default_start_today_hint">Pre-fill a new task\'s start with today\'s date</string>
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
@@ -755,9 +757,68 @@
<!-- Home-screen widget -->
<string name="widget_tasks_label">Tasks</string>
<string name="widget_tasks_description">Today\'s tasks, or any list, with a tap to tick them off</string>
<string name="widget_empty">Nothing to do here</string>
<string name="widget_empty">All caught up</string>
<string name="widget_empty_hint">Nothing left to do, enjoy it</string>
<string name="widget_unavailable">Open Agendula to finish setting up</string>
<string name="widget_complete_task">Complete %1$s</string>
<string name="widget_config_title">Show in widget</string>
<string name="widget_config_smart_lists">Smart lists</string>
<string name="widget_config_group_by_list">Group by list</string>
<string name="widget_config_group_by_list_hint">Show each list\'s tasks under its own heading</string>
<string name="widget_section_overdue">Overdue</string>
<string name="widget_section_later">Later</string>
<string name="widget_section_no_date">No date</string>
<string name="widget_config_background">Background</string>
<string name="widget_config_background_solid">Solid</string>
<string name="widget_config_background_solid_hint">Fills the widget with the theme\'s surface colour</string>
<string name="widget_config_background_tinted">Tinted</string>
<string name="widget_config_background_tinted_hint">A softer, tonal fill</string>
<string name="widget_config_background_transparent">Transparent</string>
<string name="widget_config_background_transparent_hint">No fill — tasks stay readable in their own containers</string>
<string name="widget_config_theme">Theme</string>
<string name="widget_config_theme_follow_app">Follow app</string>
<string name="widget_config_theme_light">Light</string>
<string name="widget_config_theme_dark">Dark</string>
<string name="widget_config_row_color">Row colour</string>
<string name="widget_config_row_color_neutral">Neutral</string>
<string name="widget_config_row_color_neutral_hint">Every row uses the same tonal fill</string>
<string name="widget_config_row_color_by_list">By list</string>
<string name="widget_config_row_color_by_list_hint">Each row is tinted with its list\'s own colour</string>
<string name="widget_today_label">Today</string>
<string name="widget_today_description">A progress ring for today\'s tasks</string>
<string name="widget_today_all_done">All done</string>
<string name="widget_today_nothing_due">Nothing due today</string>
<plurals name="widget_today_progress_cd">
<item quantity="one">%1$d of %2$d done</item>
<item quantity="other">%1$d of %2$d done</item>
</plurals>
<string name="widget_up_next_label">Up next</string>
<string name="widget_up_next_description">Your single nearest due task</string>
<string name="widget_up_next_empty">Nothing due</string>
<string name="widget_up_next_soon">Soon</string>
<string name="widget_up_next_tomorrow">Tomorrow</string>
<string name="widget_up_next_then">Then</string>
<string name="widget_up_next_any_list">All lists</string>
<plurals name="widget_up_next_in_hours">
<item quantity="one">in %1$d h</item>
<item quantity="other">in %1$d h</item>
</plurals>
<plurals name="widget_up_next_in_days">
<item quantity="one">in %1$d d</item>
<item quantity="other">in %1$d d</item>
</plurals>
<plurals name="widget_up_next_overdue_hours">
<item quantity="one">%1$d h overdue</item>
<item quantity="other">%1$d h overdue</item>
</plurals>
<plurals name="widget_up_next_overdue_days">
<item quantity="one">%1$d d overdue</item>
<item quantity="other">%1$d d overdue</item>
</plurals>
<string name="widget_lists_label">Lists</string>
<string name="widget_lists_description">A shortcut tile per list, with its open count</string>
<string name="widget_lists_empty">No lists to show</string>
<string name="widget_lists_config_title">Lists to show</string>
<string name="widget_week_label">Week</string>
<string name="widget_week_description">A seven-day strip of task counts</string>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:minResizeWidth="57dp"
android:minResizeHeight="57dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_lists_description"
android:updatePeriodMillis="1800000"
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_preview_lists"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_today_description"
android:updatePeriodMillis="1800000"
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_preview_today"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="56dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:minResizeWidth="110dp"
android:minResizeHeight="56dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_up_next_description"
android:updatePeriodMillis="1800000"
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_preview_up_next"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="56dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:minResizeWidth="250dp"
android:minResizeHeight="56dp"
android:resizeMode="vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_week_description"
android:updatePeriodMillis="1800000"
android:configure="de.jeanlucmakiola.agendula.widget.TaskWidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_preview_week"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -10,6 +10,9 @@ fun testTask(
status: TaskStatus = TaskStatus.NEEDS_ACTION,
priority: Priority = Priority.NONE,
due: Instant? = null,
isAllDay: Boolean = false,
completedAt: Instant? = null,
parentId: Long? = null,
): Task = Task(
taskId = id,
listId = listId,
@@ -22,14 +25,14 @@ fun testTask(
percentComplete = null,
start = null,
due = due,
isAllDay = false,
isAllDay = isAllDay,
timeZone = null,
completedAt = null,
completedAt = completedAt,
listColor = 0,
taskColor = null,
listName = null,
accountName = null,
parentId = null,
parentId = parentId,
isRecurring = false,
occurrenceStart = null,
distanceFromCurrent = 0,
@@ -1,12 +1,15 @@
package de.jeanlucmakiola.agendula.widget
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSorting
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.testTask
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
@@ -27,7 +30,9 @@ class WidgetDataTest {
@Test
fun `every offered filter round-trips through storage`() {
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + TaskFilter.OfList(42)
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
TaskFilter.OfList(42) +
TaskFilter.OfLists(setOf(1L, 2L, 3L))
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
}
@@ -38,7 +43,10 @@ class WidgetDataTest {
@Test
fun `garbage and smart lists the widget does not offer fall back to Today`() {
listOf("", "list:", "list:-3", "list:abc", "smart:COMPLETED", "smart:nope", "whatever").forEach {
listOf(
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
"smart:COMPLETED", "smart:nope", "whatever",
).forEach {
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
}
}
@@ -80,6 +88,8 @@ class WidgetDataTest {
TaskSorting.DEFAULT,
now,
newYork,
groupByList = false,
listNames = emptyMap(),
)
assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder()
}
@@ -89,13 +99,135 @@ class WidgetDataTest {
val parent = testTask(id = 1, title = "parent")
val child = testTask(id = 2, title = "child").copy(parentId = 1)
val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99)
val rows = widgetRows(listOf(parent, child, orphan), TaskSorting.DEFAULT, now, newYork)
val rows = widgetRows(
listOf(parent, child, orphan),
TaskSorting.DEFAULT,
now,
newYork,
groupByList = false,
listNames = emptyMap(),
)
assertThat(rows.map { it.title }).containsExactly("parent", "orphan")
}
@Test
fun `rows are capped`() {
val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") }
assertThat(widgetRows(many, TaskSorting.DEFAULT, now, newYork)).hasSize(MAX_WIDGET_ROWS)
assertThat(
widgetRows(many, TaskSorting.DEFAULT, now, newYork, groupByList = false, listNames = emptyMap()),
).hasSize(MAX_WIDGET_ROWS)
}
@Test
fun `grouping sorts by list name, keeping each list's own order`() {
val tasks = listOf(
testTask(id = 1, listId = 2, title = "b-later", due = at("2026-07-22T09:00:00")),
testTask(id = 2, listId = 1, title = "a-soon", due = at("2026-07-20T15:00:00")),
testTask(id = 3, listId = 1, title = "a-later", due = at("2026-07-22T09:00:00")),
)
val rows = widgetRows(
tasks,
TaskSorting.DEFAULT,
now,
newYork,
groupByList = true,
listNames = mapOf(1L to "Alpha", 2L to "Beta"),
)
assertThat(rows.map { it.title }).containsExactly("a-soon", "a-later", "b-later").inOrder()
}
// --- sections ----------------------------------------------------------
private fun row(id: Long, listId: Long = 1, dueKind: DueKind? = null, overdue: Boolean = false) = WidgetTaskRow(
taskId = id,
listId = listId,
occurrenceStart = null,
title = "t$id",
due = null,
isAllDay = false,
dueKind = dueKind,
isOverdue = overdue,
color = 0,
)
@Test
fun `due buckets appear in a fixed order and skip empty ones`() {
val rows = listOf(
row(1, dueKind = DueKind.LATER),
row(2, dueKind = DueKind.PAST, overdue = true),
row(3, dueKind = DueKind.TODAY),
)
val sections = widgetSections(rows, groupByList = false, listNames = emptyMap()) { it?.name ?: "none" }
assertThat(sections.filterIsInstance<WidgetSection.Header>().map { it.name })
.containsExactly("PAST", "TODAY", "LATER").inOrder()
}
@Test
fun `a bucket's rows keep their own order under its one header`() {
val rows = listOf(row(1, dueKind = DueKind.TODAY), row(2, dueKind = DueKind.TODAY), row(3, dueKind = DueKind.TODAY))
val sections = widgetSections(rows, groupByList = false, listNames = emptyMap()) { "x" }
assertThat(sections.filterIsInstance<WidgetSection.Header>()).hasSize(1)
assertThat(sections.filterIsInstance<WidgetSection.TaskItem>().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder()
}
@Test
fun `grouping by list keeps the widgetRows order and headers each run once`() {
val rows = listOf(row(1, listId = 1), row(2, listId = 1), row(3, listId = 2))
val sections = widgetSections(rows, groupByList = true, listNames = mapOf(1L to "Alpha", 2L to "Beta")) { "x" }
assertThat(sections.filterIsInstance<WidgetSection.Header>().map { it.name }).containsExactly("Alpha", "Beta").inOrder()
assertThat(sections.filterIsInstance<WidgetSection.TaskItem>().map { it.row.taskId }).containsExactly(1L, 2L, 3L).inOrder()
}
// --- per-widget background / theme --------------------------------------
@Test
fun `an unset background is Solid, and round-trips`() {
assertThat(parseWidgetBackground(null)).isEqualTo(WidgetBackground.SOLID)
WidgetBackground.entries.forEach { assertThat(parseWidgetBackground(it.name)).isEqualTo(it) }
assertThat(parseWidgetBackground("garbage")).isEqualTo(WidgetBackground.SOLID)
}
@Test
fun `an unset theme override follows the app, Light and Dark pin regardless`() {
assertThat(parseWidgetThemeOverride(null)).isNull()
assertThat(parseWidgetThemeOverride("garbage")).isNull()
assertThat(parseWidgetThemeOverride("LIGHT")).isEqualTo(WidgetThemeMode.LIGHT)
assertThat(parseWidgetThemeOverride("DARK")).isEqualTo(WidgetThemeMode.DARK)
}
private fun readyWithTheme(appTheme: ThemeMode) = TaskWidgetData.Ready(
filter = DEFAULT_WIDGET_FILTER,
listNames = emptyMap(),
rows = emptyList(),
groupByList = false,
dynamicColor = true,
themeMode = appTheme,
use24Hour = true,
)
@Test
fun `no override resolves to whatever the app's own theme means`() {
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM)
}
@Test
fun `an override wins over the app's own theme`() {
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(WidgetThemeMode.LIGHT)).isEqualTo(WidgetThemeMode.LIGHT)
}
@Test
fun `an unset row colour is Neutral, and round-trips`() {
assertThat(parseWidgetRowColor(null)).isEqualTo(WidgetRowColor.NEUTRAL)
WidgetRowColor.entries.forEach { assertThat(parseWidgetRowColor(it.name)).isEqualTo(it) }
assertThat(parseWidgetRowColor("garbage")).isEqualTo(WidgetRowColor.NEUTRAL)
}
@Test
fun `overdue count only counts overdue rows`() {
val ready = readyWithTheme(ThemeMode.SYSTEM).copy(
rows = listOf(row(1, overdue = true), row(2, overdue = false), row(3, overdue = true)),
)
assertThat(ready.overdueCount).isEqualTo(2)
}
}
@@ -1,179 +0,0 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Intent
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** The "when is the next local midnight" arithmetic, especially where midnight is not 00:00. */
class WidgetRolloverSchedulerTest {
private val berlin = TimeZone.of("Europe/Berlin")
private fun at(local: String, zone: TimeZone): Instant =
LocalDateTime.parse(local).toInstant(zone)
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
// --- the ordinary day ----------------------------------------------------
@Test
fun `midday rolls over at the coming midnight`() {
val next = nextRollover("2026-08-27T12:00:00")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `a second before midnight still targets tonight, not tomorrow night`() {
val next = nextRollover("2026-08-27T23:59:59")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `at midnight exactly the target is the next day, never the current instant`() {
// Re-arming after a firing must move a whole day on, or the widget wakes
// itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
assertThat(next - now).isGreaterThan(Duration.ZERO)
}
@Test
fun `re-arming from the slack instant itself moves a full day on`() {
// What actually happens in practice: the receiver runs at midnight + slack.
val now = at("2026-08-28T00:00:05", berlin)
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
.isEqualTo(at("2026-08-29T00:00:05", berlin))
}
@Test
fun `the result is always in the future for every minute of a day`() {
val zone = berlin
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
// in the past, i.e. an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes
}
}
// --- daylight saving -----------------------------------------------------
@Test
fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next - now).isLessThan(24.hours)
}
@Test
fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
// "now + 24h" would land at 23:00 on the 26th and never roll over.
val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned as the accepted trade: the widget runs an hour
// ahead until the next redraw. No live zone does this since 2019.
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
)
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
}
@Test
fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
}
// --- timezone changes ----------------------------------------------------
@Test
fun `the same instant rolls over at different times in different zones`() {
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
// follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
assertThat(tokyoNext).isNotEqualTo(berlinNext)
assertThat(tokyoNext).isLessThan(berlinNext)
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
}
@Test
fun `a half-hour offset zone still lands on its own midnight`() {
val kathmandu = TimeZone.of("Asia/Kathmandu")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2026-08-27T12:00:00", kathmandu), kathmandu,
)
val local = next.toLocalDateTime(kathmandu)
assertThat(local.date.toString()).isEqualTo("2026-08-28")
assertThat(local.hour).isEqualTo(0)
}
// --- the wiring ----------------------------------------------------------
@Test
fun `the receiver actually handles the action the alarm is sent with`() {
// The single point where the whole fix would die silently: the alarm
// fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
}
@Test
fun `a language change redraws without re-arming`() {
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS).contains(Intent.ACTION_LOCALE_CHANGED)
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).doesNotContain(Intent.ACTION_LOCALE_CHANGED)
}
@Test
fun `everything that loses the alarm re-arms it`() {
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
WidgetUpdateReceiver.ACTION_ROLLOVER,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
}
}
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.agendula.widget
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.comparator
import de.jeanlucmakiola.agendula.domain.testTask
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.time.Instant
class WidgetStatsTest {
private val newYork = ZoneId.of("America/New_York")
private val today = LocalDate.of(2026, 7, 20)
private val comparator = TaskSortOrder.DUE.comparator()
private fun at(local: String, zone: ZoneId = newYork): Instant =
Instant.fromEpochMilliseconds(LocalDateTime.parse(local).atZone(zone).toInstant().toEpochMilli())
private val now = at("2026-07-20T12:00:00")
// --- todayProgress ---------------------------------------------------
@Test
fun `a task completed today counts as done, one completed yesterday does not`() {
val completed = listOf(
testTask(id = 1, status = TaskStatus.COMPLETED, completedAt = at("2026-07-20T09:00:00")),
testTask(id = 2, status = TaskStatus.COMPLETED, completedAt = at("2026-07-19T23:00:00")),
)
val (done, _, _) = todayProgress(emptyList(), completed, comparator, now, today, newYork)
assertThat(done).isEqualTo(1)
}
@Test
fun `an all-day task due today and an overdue timed task both count as remaining`() {
val open = listOf(
testTask(id = 1, due = allDayInstantOf(today), isAllDay = true),
testTask(id = 2, due = at("2026-07-20T08:00:00")), // already past `now` (12:00)
testTask(id = 3, due = at("2026-07-21T08:00:00")), // tomorrow, not remaining
)
val (_, remaining, _) = todayProgress(open, emptyList(), comparator, now, today, newYork)
assertThat(remaining).isEqualTo(2)
}
@Test
fun `an earlier-due remaining task sorts ahead of a later one`() {
val open = listOf(
testTask(id = 1, due = at("2026-07-20T11:00:00")), // overdue, closer to now
testTask(id = 2, due = at("2026-07-20T07:00:00")), // more overdue, earliest
)
val (_, _, rows) = todayProgress(open, emptyList(), comparator, now, today, newYork)
assertThat(rows.map { it.taskId }).containsExactly(2L, 1L).inOrder()
}
// --- upcoming ----------------------------------------------------------
@Test
fun `upcoming sorts by due, overdue first, and skips undated ones`() {
val open = listOf(
testTask(id = 1, due = null),
testTask(id = 2, due = at("2026-07-21T08:00:00")),
testTask(id = 3, due = at("2026-07-20T08:00:00")), // overdue, earliest
)
assertThat(upcoming(open, now, today, newYork).map { it.taskId }).containsExactly(3L, 2L).inOrder()
}
@Test
fun `upcoming is empty when nothing is dated`() {
assertThat(upcoming(listOf(testTask(due = null)), now, today, newYork)).isEmpty()
}
// --- weekDays ------------------------------------------------------------
@Test
fun `today's bucket folds in overdue tasks from earlier days`() {
val open = listOf(
testTask(id = 1, due = at("2026-07-18T08:00:00")), // two days overdue
testTask(id = 2, due = at("2026-07-20T08:00:00")), // due today, already past now
)
val week = weekDays(open, comparator, now, today, newYork)
assertThat(week.first().date).isEqualTo(today)
assertThat(week.first().count).isEqualTo(2)
assertThat(week.first().overdueCount).isEqualTo(2)
}
@Test
fun `a future day only counts tasks due that exact day`() {
val open = listOf(
testTask(id = 1, due = at("2026-07-22T08:00:00")),
testTask(id = 2, due = at("2026-07-23T08:00:00")),
)
val week = weekDays(open, comparator, now, today, newYork)
assertThat(week[2].count).isEqualTo(1) // today + 2 = 07-22
assertThat(week[2].overdueCount).isEqualTo(0)
assertThat(week[3].count).isEqualTo(1) // today + 3 = 07-23
}
@Test
fun `a day at the far edge of daylight saving still buckets by local date`() {
// Spans the US spring-forward boundary; only exercised to confirm
// calendarDate (not a raw day-of-year add) drives the bucketing.
val dstToday = LocalDate.of(2026, 3, 7)
val dstNow = at("2026-03-07T12:00:00")
val open = listOf(testTask(id = 1, due = at("2026-03-08T08:00:00")))
val week = weekDays(open, comparator, dstNow, dstToday, newYork)
assertThat(week[1].date).isEqualTo(LocalDate.of(2026, 3, 8))
assertThat(week[1].count).isEqualTo(1)
}
// --- listTiles -------------------------------------------------------
@Test
fun `listTiles counts open tasks per visible list and skips a subtask whose parent is also open`() {
val lists = listOf(
TaskList(1, "Home", 0, "a", "t", false, isVisible = true, owner = null),
TaskList(2, "Hidden", 0, "a", "t", false, isVisible = false, owner = null),
)
val open = listOf(
testTask(id = 1, listId = 1),
testTask(id = 2, listId = 1, parentId = 1), // subtask of an open parent
testTask(id = 3, listId = 2),
)
val tiles = listTiles(open, lists)
assertThat(tiles).hasSize(1)
assertThat(tiles.single().id).isEqualTo(1L)
assertThat(tiles.single().openCount).isEqualTo(1)
}
}
+1
View File
@@ -177,6 +177,7 @@ floret-core-locale = { group = "de.jeanlucmakiola.floret", name = "core-locale"
floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" }
floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" }
floret-components = { group = "de.jeanlucmakiola.floret", name = "components" }
floret-glance = { group = "de.jeanlucmakiola.floret", name = "glance" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }