Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebaf5179de | ||
|
|
55fafb66c2 | ||
|
|
7eb1ebbfa3 | ||
|
|
7a82f7c3fa | ||
|
|
53c518fbe2 | ||
|
|
53d5e2c5aa | ||
|
|
6888965f5a | ||
|
|
f1ba21e7fa | ||
|
|
8abc44bbdf | ||
|
|
a6e3a35f04 | ||
|
|
a3de9f3d93 | ||
|
|
c823243e53 |
@@ -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).
|
||||
|
||||
@@ -244,6 +244,7 @@ dependencies {
|
||||
implementation(libs.floret.core.crash)
|
||||
implementation(libs.floret.identity)
|
||||
implementation(libs.floret.components)
|
||||
implementation(libs.floret.glance)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
|
||||
@@ -185,6 +185,58 @@
|
||||
android:resource="@xml/appwidget_info_tasks" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Today" home-screen widget (Glance): a progress ring for today's tasks. -->
|
||||
<receiver
|
||||
android:name=".widget.TodayWidgetReceiver"
|
||||
android:label="@string/widget_today_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_today" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Up next" home-screen widget (Glance): the single nearest due task. -->
|
||||
<receiver
|
||||
android:name=".widget.UpNextWidgetReceiver"
|
||||
android:label="@string/widget_up_next_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_up_next" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Lists" home-screen widget (Glance): one shortcut tile per list. -->
|
||||
<receiver
|
||||
android:name=".widget.ListsWidgetReceiver"
|
||||
android:label="@string/widget_lists_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_lists" />
|
||||
</receiver>
|
||||
|
||||
<!-- "Week" home-screen widget (Glance): a seven-day strip of task counts. -->
|
||||
<receiver
|
||||
android:name=".widget.WeekWidgetReceiver"
|
||||
android:label="@string/widget_week_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_week" />
|
||||
</receiver>
|
||||
|
||||
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
||||
<activity
|
||||
android:name=".widget.TaskWidgetConfigActivity"
|
||||
|
||||
@@ -186,6 +186,7 @@ class MainActivity : ComponentActivity() {
|
||||
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||
private const val SHARED_TITLE_LIMIT = 500
|
||||
private const val NO_OCCURRENCE = -1L
|
||||
|
||||
@@ -206,9 +207,14 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/** The launcher shortcut's and Quick Settings tile's "New task". */
|
||||
fun newTaskIntent(context: Context): Intent =
|
||||
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
|
||||
/**
|
||||
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
||||
* task". [listId] preset the list when it names exactly one; the data
|
||||
* URI keeps each widget's PendingIntent apart from the others.
|
||||
*/
|
||||
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
|
||||
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
fun todayIntent(context: Context): Intent =
|
||||
@@ -233,7 +239,9 @@ class MainActivity : ComponentActivity() {
|
||||
?.let { return NavRequest.OpenAccount(it) }
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||
when (intent.action) {
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask()
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||
)
|
||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
||||
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
-1
Submodule floret-kit updated: db4be68dad...cb109c0e82
@@ -177,6 +177,7 @@ floret-core-locale = { group = "de.jeanlucmakiola.floret", name = "core-locale"
|
||||
floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" }
|
||||
floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" }
|
||||
floret-components = { group = "de.jeanlucmakiola.floret", name = "components" }
|
||||
floret-glance = { group = "de.jeanlucmakiola.floret", name = "glance" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user