Release 1.3.0 (#134)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 1m7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m49s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped

Stable 1.3.0, after 1.3.0-beta.1 and beta.2.

- Version bumped to 1.3.0 (1030099); the CHANGELOG entries moved from Unreleased to 1.3.0.
- What's New for every shipped locale, now including ru-RU, which also gets its full store listing. values-uk and values-zh-rCN came in empty from Weblate, so they get no store locale yet.
- fr, ar, cs and ru added to locales_config.xml, so they show up in the in-app language picker and Android's per-app language settings. Before, only en, de and pt-BR did.
- `main` (Weblate #87) merged in, no conflicts and no stale keys.
- Lint has failed on this branch since #113: `NavController.currentBackStack` is a RestrictedApi. It's suppressed at that one call, since no public API exposes the whole back stack and `visibleEntries` drops an entry once another one covers it.

#119, #121, #122/#123 and #127/#129 landed after beta.2, so no tester has seen them yet.

Closes #68, closes #73, closes #90, closes #91, closes #93, closes #94, closes #99, closes #104, closes #105, closes #119, closes #121, closes #122, closes #123, closes #127, closes #129

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/134
This commit is contained in:
Jean-Luc Makiolaandmakiolaj committed 2026-11-02 21:26:15 +01:00
1 parent 023c0e0157
commit 7bcac70cec
129 files changed
+3799 -343

No files matched your search

@@ -12,6 +12,7 @@ import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
@@ -65,15 +66,12 @@ class AgendulaApp : Application(), Configuration.Provider {
// v0.3.x install's tasks, both before anything reads a store.
val startupGate = entryPoint.startupGate()
val scope = entryPoint.applicationScope()
// An alarm is armed off whichever store was active when it was scheduled,
// so a switch has to rebuild the set. Armed only once startup's own
// null -> stored transition is past, which the launch sync below covers.
// A switch rebuilds the alarms through ReminderRefresher, which hears it
// as a store change. Channels only once startup's own null -> stored
// transition is past, which the launch sync below covers.
val started = AtomicBoolean(false)
entryPoint.providerResolver().onModeChanged {
if (started.get()) {
scope.launch { runCatching { scheduler.sync() } }
runCatching { entryPoint.taskNotifier().refreshChannel() }
}
if (started.get()) runCatching { entryPoint.taskNotifier().refreshChannel() }
}
startupGate.start()
refreshNotificationChannels()
@@ -88,6 +86,7 @@ class AgendulaApp : Application(), Configuration.Provider {
scheduler.sync()
}
runCatching { entryPoint.taskWidgetUpdater().start() }
runCatching { entryPoint.reminderRefresher().start() }
runCatching { entryPoint.providerSnapshotSource().start() }
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
}
@@ -124,6 +123,7 @@ class AgendulaApp : Application(), Configuration.Provider {
fun taskNotifier(): TaskNotifier
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater
fun reminderRefresher(): ReminderRefresher
fun providerSnapshotSource(): ProviderSnapshotSource
}
}
@@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
@@ -28,15 +29,17 @@ import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.agendula.ui.theme.SystemTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -50,6 +53,8 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
@Inject lateinit var settingsPrefs: SettingsPrefs
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
@@ -73,6 +78,13 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
AppShortcuts.publish(this)
// The stored setting, never the view model's placeholder: a night mode
// that flips back and forth would recreate the activity each time.
lifecycleScope.launch {
settingsPrefs.settings.map { it.themeMode to it.dynamicColor }.distinctUntilChanged()
.collect { (mode, dynamicColor) -> SystemTheme.apply(this@MainActivity, mode, dynamicColor) }
}
// Only on a fresh launch: after a rotation or process restore the intent
// is the same old one and the back stack already holds its destination.
val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
@@ -88,15 +100,15 @@ class MainActivity : ComponentActivity() {
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (ui.settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
val darkTheme = ui.settings.themeMode.isDark(isSystemInDarkTheme())
val context = LocalContext.current
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
AgendulaTheme(
darkTheme = darkTheme,
dynamicColor = ui.settings.dynamicColor,
pureBlack = ui.settings.themeMode.isPureBlack,
) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalFirstDayOfWeek provides firstDayOfWeek,
@@ -105,6 +117,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
navRequest = navRequest,
onNavRequestConsumed = { navRequest = null },
onReturnHome = ::finish,
)
pendingCrashReport?.let { report ->
CrashReportDialog(
@@ -153,7 +166,9 @@ class MainActivity : ComponentActivity() {
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
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_RETURN_HOME = "de.jeanlucmakiola.agendula.extra.RETURN_HOME"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val EXTRA_DUE_TODAY = "de.jeanlucmakiola.agendula.extra.DUE_TODAY"
private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L
@@ -161,10 +176,16 @@ class MainActivity : ComponentActivity() {
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
* picks the occurrence of a recurring task.
*/
fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent =
fun taskIntent(
context: Context,
taskId: Long,
occurrenceStart: Long? = null,
returnHome: Boolean = false,
): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId)
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
putExtra(EXTRA_RETURN_HOME, returnHome)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
@@ -176,13 +197,17 @@ class MainActivity : ComponentActivity() {
/**
* 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.
* task". [listId] preset the list when it names exactly one, [dueToday]
* the due date for a Today widget; 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)
fun newTaskIntent(context: Context, listId: Long? = null, dueToday: Boolean = false): Intent {
val path = listOfNotNull(listId?.toString(), "today".takeIf { dueToday }).joinToString("/")
return Intent(ACTION_NEW_TASK, path.ifEmpty { null }?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
.putExtra(EXTRA_DUE_TODAY, dueToday)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
fun todayIntent(context: Context): Intent =
Intent(ACTION_TODAY, null, context, MainActivity::class.java)
@@ -216,6 +241,8 @@ class MainActivity : ComponentActivity() {
when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
dueToday = intent.getBooleanExtra(EXTRA_DUE_TODAY, false),
returnHome = true,
)
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
@@ -230,12 +257,12 @@ class MainActivity : ComponentActivity() {
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
?: intent.getStringExtra(Intent.EXTRA_TEXT)
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT), returnHome = true)
}
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
.takeIf { it != NO_OCCURRENCE }
return NavRequest.OpenTask(taskId, occurrence)
return NavRequest.OpenTask(taskId, occurrence, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
}
}
}
@@ -0,0 +1,86 @@
package de.jeanlucmakiola.agendula.data.appname
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import javax.inject.Inject
import javax.inject.Singleton
/** The label the launcher shows for the app (#104). */
enum class LauncherName { AGENDULA, TASKS }
/** The two launcher aliases declared in the manifest. */
enum class LauncherAlias { DEFAULT, TASKS }
/** One component-enable change in an [aliasWritePlan]. */
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
/**
* Reads the `TasksNameAlias` enabled state as a [LauncherName]. Only an explicit
* enable means "Tasks"; never toggled and disabled both mean the manifest default.
*/
fun launcherNameFor(tasksAliasState: Int): LauncherName =
if (tasksAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
LauncherName.TASKS
} else {
LauncherName.AGENDULA
}
/**
* The steps to switch to [target]: enable the target alias first, then disable
* the other, so the launcher can briefly see two entries but never none.
*/
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
LauncherName.TASKS -> listOf(
AliasStateChange(LauncherAlias.TASKS, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
)
LauncherName.AGENDULA -> listOf(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.TASKS, enabled = false),
)
}
/**
* Switches the launcher label by enabling one of the two `<activity-alias>`
* components. The component state is the only source of truth; nothing is
* stored in prefs.
*/
@Singleton
class LauncherNameManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val packageManager: PackageManager get() = context.packageManager
// The package is the (suffixed) applicationId, the class is namespace-qualified.
private fun component(alias: LauncherAlias): ComponentName {
val simpleName = when (alias) {
LauncherAlias.DEFAULT -> "DefaultNameAlias"
LauncherAlias.TASKS -> "TasksNameAlias"
}
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
}
fun current(): LauncherName =
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.TASKS)))
fun set(name: LauncherName) {
for (change in aliasWritePlan(name)) {
val state = if (change.enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
packageManager.setComponentEnabledSetting(component(change.alias), state, PackageManager.DONT_KILL_APP)
}
// Dynamic shortcuts hang off the launcher activity, which just changed.
AppShortcuts.publish(context)
}
private companion object {
/** `namespace` in app/build.gradle.kts, not the suffixed applicationId. */
const val NAMESPACE = "de.jeanlucmakiola.agendula"
}
}
@@ -89,7 +89,7 @@ object DataProvideModule {
// backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4)
.build()
/**
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
@@ -31,7 +32,18 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** [BLACK] is dark with pure-black surfaces, for AMOLED screens. */
enum class ThemeMode {
SYSTEM, LIGHT, DARK, BLACK;
fun isDark(systemDark: Boolean): Boolean = when (this) {
SYSTEM -> systemDark
LIGHT -> false
DARK, BLACK -> true
}
val isPureBlack: Boolean get() = this == BLACK
}
/** Clock convention: AUTO follows the device's 24-hour switch. */
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
@@ -43,6 +55,7 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
}
const val DEFAULT_SNOOZE_MINUTES = 10
/** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set<TaskSection> = setOf(TaskSection.COMPLETED)
/** The snooze lengths Settings offers. */
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
@@ -103,6 +116,8 @@ data class Settings(
val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false,
/** Task-list sections folded away; Done starts folded. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
) {
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -168,9 +183,18 @@ class SettingsPrefs @Inject constructor(
.toSet()
},
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
collapsedSections = p[COLLAPSED_SECTIONS]
?.mapNotNull { name -> runCatching { TaskSection.valueOf(name) }.getOrNull() }
?.toSet()
?: DEFAULT_COLLAPSED_SECTIONS,
)
}
suspend fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) = dataStore.edit { p ->
val current = p[COLLAPSED_SECTIONS] ?: DEFAULT_COLLAPSED_SECTIONS.mapTo(HashSet()) { it.name }
p[COLLAPSED_SECTIONS] = if (collapsed) current + section.name else current - section.name
}
suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
@@ -328,6 +352,7 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
}
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
@@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Instant
/**
* Fires when a task's reminder alarm goes off. Re-reads the task (it may have
@@ -44,8 +46,7 @@ class DueReminderReceiver : BroadcastReceiver() {
}
val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@launch
val task = runCatching { dataSource.occurrence(taskId, occurrence) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task, settings)
runCatching { notifier.postIfOpen(dataSource, taskId, occurrence, settings) }
} finally {
pending.finish()
}
@@ -74,17 +75,51 @@ class DueReminderReceiver : BroadcastReceiver() {
/**
* The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the
* task's current one when there is no anchor or it can no longer be found.
* [TasksDataSource.task] alone resolves a series to whichever occurrence is
* current, which is not necessarily the one a reminder was armed for.
*
* ⚠️ A ticked or edited occurrence is a row of its own with its own id, so
* matching list rows on [taskId] misses it; [TasksDataSource.task] with the
* anchor finds it by start. The list scan is for a provider whose exception
* rows that lookup does not reach.
*/
internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? {
val current = task(taskId) ?: return null
if (occurrenceStart == ScheduledReminder.NO_OCCURRENCE ||
current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
) {
return current
val anchor = occurrenceStart.takeIf { it != ScheduledReminder.NO_OCCURRENCE }?.let(Instant::fromEpochMilliseconds)
val found = task(taskId, anchor) ?: return null
if (anchor == null || found.occurrenceStart == anchor) return found
return tasks(TaskQuery(listId = found.listId, includeCompleted = true))
.firstOrNull { (it.taskId == taskId || it.seriesId == taskId) && it.occurrenceStart == anchor }
?: found
}
/**
* Whether a reminder posted for [occurrenceStart] is done with, given [current]
* as [occurrence] resolved it.
*
* - No task: gone, but only when [missingMeansGone] — a provider that fails a
* read hands back nothing rather than throwing.
* - [occurrenceStart] null: posted before reminders recorded their occurrence,
* so only a task that does not recur can be judged.
* - A lookup that fell back to another occurrence says nothing about the
* reminded one, so it stays.
*/
internal fun reminderIsStale(occurrenceStart: Long?, current: Task?, missingMeansGone: Boolean): Boolean {
if (current == null) return missingMeansGone
val same = when (occurrenceStart) {
null -> current.occurrenceStart == null
ScheduledReminder.NO_OCCURRENCE -> true
else -> current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
}
return tasks(TaskQuery(listId = current.listId, includeCompleted = true))
.firstOrNull { it.taskId == taskId && it.occurrenceStart?.toEpochMilliseconds() == occurrenceStart }
?: current
return same && current.isClosed
}
/**
* Posts the reminder for [taskId]'s occurrence if it is still open, then checks
* once more: a completion landing between the read and the post may already
* have had its withdrawal pass, which ran before there was anything to withdraw.
*/
internal fun TaskNotifier.postIfOpen(dataSource: TasksDataSource, taskId: Long, occurrenceStart: Long, settings: Settings) {
val task = dataSource.occurrence(taskId, occurrenceStart) ?: return
if (task.isClosed) return
postDue(task, settings)
val now = runCatching { dataSource.occurrence(taskId, occurrenceStart) }.getOrElse { return }
if (now == null || now.isClosed) cancel(taskId)
}
@@ -32,7 +32,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
@Inject lateinit var notifier: TaskNotifier
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
@Inject lateinit var reminderScheduler: ReminderScheduler
@Inject lateinit var repository: TasksRepository
@Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var settingsPrefs: SettingsPrefs
@@ -54,7 +53,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE }
?.let(Instant::fromEpochMilliseconds)
repository.setCompleted(taskId, anchor, completed = true)
reminderScheduler.sync()
}
ACTION_SNOOZE -> runCatching {
@@ -67,8 +65,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@runCatching
startupGate.awaitReady()
val task = dataSource.occurrence(taskId, occurrence)
if (task != null && !task.isClosed) notifier.postDue(task, settings)
notifier.postIfOpen(dataSource, taskId, occurrence, settings)
}
}
} finally {
@@ -21,7 +21,9 @@ internal data class ReminderDiff(
fired: Set<ScheduledReminder> = emptySet(),
isArmed: (ScheduledReminder) -> Boolean,
): ReminderDiff {
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) }
// A fired entry counts even once the store dropped it, so a task
// reopened within the grace does not ring again.
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) } + fired
return ReminderDiff(
cancel = live - desired,
arm = desired - live,
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.data.reminders
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps reminders in step with the store: any change to tasks, lists or alarms —
* a tick in the list or on a widget, a sync pulling in a completion from another
* device, an edit, a store switch — runs one debounced [ReminderScheduler.sync].
* Started once from `AgendulaApp`, after the startup gate, so it observes the
* right store.
*/
@Singleton
class ReminderRefresher @Inject constructor(
dataSource: TasksDataSource,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) {
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerTaskObserver) { scheduler.sync() }
fun start() {
changes.start()
}
/** Re-sync soon, folded into any pass a store change already asked for. */
fun requestRefresh() = changes.request()
private companion object {
const val DEBOUNCE_MILLIS = 1_000L
}
}
@@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher
@@ -23,8 +24,9 @@ import javax.inject.Singleton
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
* and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
* on app start, on boot, on a store switch, and on an external provider change;
* it diffs against [ScheduledReminderStore] so only changed alarms move.
* on app start, on boot, on a store switch and on every change to the store (see
* [ReminderRefresher]); it diffs against [ScheduledReminderStore] so only changed
* alarms move, and takes down posted reminders whose task is done with.
*/
@Singleton
class ReminderScheduler @Inject constructor(
@@ -34,6 +36,7 @@ class ReminderScheduler @Inject constructor(
private val store: ScheduledReminderStore,
private val providerResolver: ProviderResolver,
private val snoozeScheduler: ReminderSnoozeScheduler,
private val notifier: TaskNotifier,
@IoDispatcher private val io: CoroutineDispatcher,
) {
private val syncLock = Mutex()
@@ -65,19 +68,23 @@ class ReminderScheduler @Inject constructor(
// Gate on whether the store is readable, not on whether a provider
// resolves: our own store deliberately resolves to no provider, so the
// latter clears every reminder in the default mode.
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
val readable = providerResolver.canReadStore()
if (!settings.remindersEnabled || !readable) {
clearAll()
// Switched off, a reminder already up still goes when its task does.
if (readable) withdrawStale()
return
}
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return }
// Per-task leads. One query for all of them.
val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() }
val now = System.currentTimeMillis()
val desired = ReminderPlanner.plan(
tasks = tasks,
perTask = perTask,
settings = settings,
now = System.currentTimeMillis(),
now = now,
zone = ZoneId.systemDefault(),
)
@@ -92,7 +99,10 @@ class ReminderScheduler @Inject constructor(
// ⚠️ The store survives reboot, force-stop and restore; the alarms don't.
// Trusting it alone left every reminder after a reboot unarmed for good.
val fired = store.fired()
// ⚠️ Kept through the missed-reminder grace even once it drops out of
// the plan: a tick undone a moment later would otherwise re-arm a
// trigger in the past, and the reminder would ring a second time.
val fired = store.fired().filterTo(HashSet()) { it in desired || it.triggerAt >= now - ReminderPlanner.MISSED_GRACE_MS }
val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) }
diff.cancel.forEach { cancel(it) }
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the
@@ -100,7 +110,25 @@ class ReminderScheduler @Inject constructor(
// — uncancellable, and firing for tasks that no longer exist — and the
// exception escaped into BootReceiver's goAsync().
val armed = diff.arm.filter { schedule(it) }
store.replace(diff.keep + armed, fired = fired intersect desired, exact = canExact)
store.replace(diff.keep + armed, fired = fired, exact = canExact)
withdrawStale()
}
/**
* A reminder already on screen outlives its alarm: completing the task in
* the list, on a widget or on another device has to take it down too.
*/
private fun withdrawStale() {
// Only our own store tells a deleted task from a failed read.
val missingMeansGone = providerResolver.mode() == StorageMode.OWN
runCatching {
notifier.withdrawStale { taskId, occurrence ->
runCatching {
val current = dataSource.occurrence(taskId, occurrence ?: ScheduledReminder.NO_OCCURRENCE)
reminderIsStale(occurrence, current, missingMeansGone)
}.getOrDefault(false)
}
}
}
/** Whether the system still holds this alarm's PendingIntent. */
@@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.text.format.DateFormat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -18,6 +19,7 @@ import androidx.core.content.edit
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.domain.readableDescription
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
@@ -84,6 +86,7 @@ class TaskNotifier @Inject constructor(
),
)
}
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
val tapIntent = PendingIntent.getActivity(
context,
task.taskId.toInt(),
@@ -93,11 +96,19 @@ class TaskNotifier @Inject constructor(
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.apply { if (text != null) setContentText(text) }
.apply { (text ?: description)?.let(::setContentText) }
.apply {
expandedText(text, description)?.let { setStyle(NotificationCompat.BigTextStyle().bigText(it)) }
}
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(tapIntent)
.addExtras(
Bundle().apply {
putLong(EXTRA_OCCURRENCE, task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE)
},
)
.addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE))
.addAction(
0,
@@ -112,6 +123,24 @@ class TaskNotifier @Inject constructor(
NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
}
/**
* Takes down every posted reminder [isStale] says is done with — completed,
* cancelled or deleted since it went off, here or on another device. The
* occurrence is null for a reminder posted before it was recorded.
*/
fun withdrawStale(isStale: (taskId: Long, occurrenceStart: Long?) -> Boolean) {
// Only the current store's: another store's task ids name other tasks.
val prefix = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_"
manager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.notification.channelId?.startsWith(prefix) == true }
.forEach { posted ->
val taskId = posted.tag?.toLongOrNull() ?: return@forEach
val extras = posted.notification.extras
val occurrence = if (extras.containsKey(EXTRA_OCCURRENCE)) extras.getLong(EXTRA_OCCURRENCE) else null
if (isStale(taskId, occurrence)) cancel(taskId)
}
}
private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
context,
task.taskId.toInt(),
@@ -185,6 +214,7 @@ class TaskNotifier @Inject constructor(
private const val LEGACY_CHANNEL_ID = "task_reminders"
private const val LIST_CHANNEL_PREFIX = "reminders_"
private const val NOTIFICATION_ID = 1
private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val CHANNEL_PREFS = "reminder_channels"
private const val KEY_LEGACY_IMPORTANCE = "legacy_importance"
@@ -240,3 +270,10 @@ private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays,
else -> null
}
}
/** The expanded body of a due reminder: the due line, then the task's description. */
internal fun expandedText(dueLine: String?, description: String?): String? = when {
description == null -> null
dueLine == null -> description
else -> "$dueLine\n\n$description"
}
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.agendula.data.sync
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
/** Pull to sync (#73): syncs every account at once. Inert in the `offline` flavor. */
interface ManualSync {
/** Whether there is anything to sync: our own store, with at least one account. */
val available: Flow<Boolean>
/** True from [syncAll] until the syncs it started have finished. */
val syncing: StateFlow<Boolean>
fun syncAll()
object None : ManualSync {
override val available: Flow<Boolean> = flowOf(false)
override val syncing: StateFlow<Boolean> = MutableStateFlow(false)
override fun syncAll() = Unit
}
}
@@ -2,5 +2,5 @@ package de.jeanlucmakiola.agendula.data.sync
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
fun interface SyncRequests {
fun syncNow(accountName: String)
suspend fun syncNow(accountId: Long)
}
@@ -81,16 +81,22 @@ class ModeRoutingTasksDataSource(
* So the registration moves with the mode, and the switch itself counts as a
* change: the data underneath every live flow has just been replaced.
*/
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
override fun registerObserver(onChange: () -> Unit): AutoCloseable =
follow(onChange) { it.registerObserver(onChange) }
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable =
follow(onChange) { it.registerTaskObserver(onChange) }
private fun follow(onChange: () -> Unit, register: (TasksDataSource) -> AutoCloseable): AutoCloseable {
val lock = Any()
var closed = false
var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
var handle: AutoCloseable? = runCatching { register(active()) }.getOrNull()
val modeHandle = resolver.onModeChanged {
synchronized(lock) {
if (!closed) {
handle?.let { runCatching { it.close() } }
handle = runCatching { active().registerObserver(onChange) }.getOrNull()
handle = runCatching { register(active()) }.getOrNull()
}
}
onChange()
@@ -0,0 +1,39 @@
package de.jeanlucmakiola.agendula.data.tasks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* Runs [action] once per burst of store changes: [register] hooks the store's
* observer, and [request] asks for a pass without one. Started at most once.
*/
class StoreChangeDebouncer(
private val scope: CoroutineScope,
private val delayMillis: Long,
private val register: (onChange: () -> Unit) -> AutoCloseable,
private val action: suspend () -> Unit,
) {
private val requests = Channel<Unit>(Channel.CONFLATED)
private val started = AtomicBoolean(false)
/** @return whether this call started it; false when it already was. */
@OptIn(FlowPreview::class)
fun start(): Boolean {
if (!started.compareAndSet(false, true)) return false
runCatching { register { requests.trySend(Unit) } }
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(delayMillis).collect { runCatching { action() } }
}
return true
}
fun request() {
requests.trySend(Unit)
}
}
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.calendarDate
import java.time.ZoneId
import kotlin.time.Instant
@@ -22,7 +23,8 @@ internal object StoredReminders {
*
* A task getting its first date takes the defaults only when the form
* names no reminder; one that already had a date takes those of the new
* date that no reminder of the form counts from. Otherwise the form is
* date that no reminder of the form counts from, and those of a date
* whose defaults had all rung when it moved — see [revived]. Otherwise the form is
* kept, so an edit that clears every reminder means "use the default
* here" as before.
*
@@ -33,7 +35,7 @@ internal object StoredReminders {
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
val wanted = form.reminders.map { it.lead }.distinct()
if (form.remindersChosen) return wanted
return withDefaults(form, current?.start, current?.due, settings, now, zone)
return withDefaults(form, current?.start, current?.due, current?.isAllDay ?: form.isAllDay, settings, now, zone)
}
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
@@ -41,6 +43,7 @@ internal object StoredReminders {
form: TaskForm,
start: Instant?,
due: Instant?,
wasAllDay: Boolean,
settings: Settings,
now: Long,
zone: ZoneId,
@@ -48,10 +51,12 @@ internal object StoredReminders {
val wanted = form.reminders.map { it.lead }.distinct()
val had = anchorsOf(start, due)
val gained = anchorsOf(form.start, form.due) - had
if (gained.isEmpty()) return wanted
val revived = revived(form, start, due, wasAllDay, wanted, settings, now, zone)
if (gained.isEmpty() && revived.isEmpty()) return wanted
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
val named = wanted.map { it.fromStart }.toSet()
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
val fresh = gained + revived
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in fresh && it.fromStart !in named }
}
/**
@@ -79,6 +84,41 @@ internal object StoredReminders {
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
}
/**
* The dates of a one-off task that moved after every default of theirs
* had rung: a task saved then was left without them as past (#121), so
* the new date brings them like a date the task gains. Only on a task
* with reminders of its own and none from that date — no reminders at
* all is the planner's default or turned off, and either holds.
*/
private fun revived(
form: TaskForm,
start: Instant?,
due: Instant?,
wasAllDay: Boolean,
wanted: List<TaskReminder>,
settings: Settings,
now: Long,
zone: ZoneId,
): Set<Boolean> {
if (form.rrule != null || wanted.isEmpty()) return emptySet()
val named = wanted.map { it.fromStart }.toSet()
val defaults = settings.defaultRemindersFor(form.listId, wasAllDay)
fun revived(fromStart: Boolean, was: Instant?, isNow: Instant?): Boolean {
if (was == null || isNow == null || fromStart in named) return false
val moved = if (wasAllDay == form.isAllDay) {
isNow != was
} else {
isNow.calendarDate(form.isAllDay, zone) != was.calendarDate(wasAllDay, zone)
}
val leads = defaults.filter { it.fromStart == fromStart }
return moved && leads.isNotEmpty() && leads.all {
ReminderPlanner.triggerAt(was, wasAllDay, it.minutesBefore, settings.allDayReminderMinuteOfDay, zone) < now
}
}
return setOfNotNull(true.takeIf { revived(true, start, form.start) }, false.takeIf { revived(false, due, form.due) })
}
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
@@ -152,4 +152,7 @@ interface TasksDataSource {
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
fun registerObserver(onChange: () -> Unit): AutoCloseable
/** Like [registerObserver], but only for changes to tasks, lists and alarms — not account bookkeeping. */
fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = registerObserver(onChange)
}
@@ -1,8 +1,7 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.sqlite.SQLiteConstraintException
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
@@ -19,8 +18,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.normalizeTags
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.util.UUID
import javax.inject.Inject
@@ -58,9 +55,8 @@ class ProviderWriter internal constructor(
store: RoomTasksDataSource,
source: ProviderSnapshotSource,
resolver: ProviderResolver,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
reminders: ReminderRefresher,
) : this(database, store, source, resolver, reminders::requestRefresh)
private var batchDepth = 0
private var batchWrote = false
@@ -37,6 +37,9 @@ interface AccountDao {
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
fun recordSync(accountId: Long, at: Instant?, error: String?)
@Query("UPDATE accounts SET label = :label WHERE id = :accountId")
fun setLabel(accountId: Long, label: String?)
@Query("DELETE FROM accounts WHERE id = :accountId")
fun delete(accountId: Long): Int
}
@@ -26,6 +26,8 @@ data class AccountEntity(
@ColumnInfo(name = "username") val username: String? = null,
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
/** A name the user gave the account (#105); [displayName] stays the stable key. */
@ColumnInfo(name = "label") val label: String? = null,
)
/**
@@ -11,6 +11,7 @@ import androidx.room.Embedded
data class TaskListRow(
@Embedded val list: TaskListEntity,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
)
/**
@@ -23,5 +24,6 @@ data class TaskRow(
@ColumnInfo(name = "list_name") val listName: String,
@ColumnInfo(name = "list_color") val listColor: Int,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
@ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false,
)
@@ -20,7 +20,7 @@ object RoomTaskMapper {
color = row.list.color,
// TaskList.accountName is non-null and the lists screen groups by it, so a
// list with no account still has to report something to group under.
accountName = row.accountDisplayName ?: LocalAccount.NAME,
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
isSynced = row.list.isSynced,
isVisible = row.list.isVisible,
@@ -61,7 +61,7 @@ object RoomTaskMapper {
listColor = row.listColor,
taskColor = row.task.color,
listName = row.listName,
accountName = row.accountDisplayName ?: LocalAccount.NAME,
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
parentId = row.task.parentId,
isRecurring = row.task.isRecurring,
occurrenceStart = occurrenceStart,
@@ -76,6 +76,7 @@ internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>,
}
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms")
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
private const val ACK_CACHE_SIZE = 512
@@ -650,8 +651,12 @@ class RoomTasksDataSource @Inject constructor(
// --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun registerObserver(onChange: () -> Unit): AutoCloseable = observe(OBSERVED_TABLES, onChange)
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = observe(TASK_TABLES, onChange)
private fun observe(tables: Array<String>, onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(tables) {
override fun onInvalidated(tables: Set<String>) = onChange()
}
database.invalidationTracker.addObserver(observer)
@@ -29,7 +29,7 @@ interface TaskDao {
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
a.display_name AS account_display_name
a.display_name AS account_display_name, a.label AS account_label
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
@@ -44,7 +44,7 @@ interface TaskDao {
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
a.display_name AS account_display_name
a.display_name AS account_display_name, a.label AS account_label
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
@@ -56,7 +56,7 @@ interface TaskDao {
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
a.display_name AS account_display_name
a.display_name AS account_display_name, a.label AS account_label
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
@@ -16,7 +16,7 @@ interface TaskListDao {
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
ORDER BY a.display_name, l.sort_order, l.name
"""
@@ -25,7 +25,7 @@ interface TaskListDao {
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
WHERE l.id = :listId
"""
@@ -22,7 +22,7 @@ import kotlin.time.Instant
TaskEntity::class,
TaskAlarmEntity::class,
],
version = 3,
version = 4,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -173,5 +173,12 @@ abstract class TasksDatabase : RoomDatabase() {
)
}
}
/** v4 lets the user name an account (#105). */
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT")
}
}
}
}
@@ -59,7 +59,7 @@ fun dueWindowOf(
todayEnd: Instant,
zone: ZoneId = ZoneId.systemDefault(),
): DueWindow {
val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
val due = task.wallClock(task.due, zone)
return when {
due == null -> DueWindow.NO_DATE
due < todayStart -> DueWindow.OVERDUE
@@ -68,6 +68,10 @@ fun dueWindowOf(
}
}
/** [at], one of this task's dates, as a local instant: an all-day date is read as its calendar date. */
fun Task.wallClock(at: Instant?, zone: ZoneId = ZoneId.systemDefault()): Instant? =
at?.let { if (isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
data class NewTaskPreset(
val listIds: Set<Long> = emptySet(),
@@ -1,8 +1,13 @@
package de.jeanlucmakiola.agendula.domain
import java.time.ZoneId
import kotlin.time.Instant
/** The built-in "smart" lists, computed from due dates rather than membership. */
/**
* The built-in "smart" lists, computed from dates rather than membership. All
* but [COMPLETED] hold open tasks, except [TODAY], which also keeps the ones
* completed today (see [TaskFiltering.isToday]).
*/
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
/** What a task screen is showing: one real list, a smart list, or a tag. */
@@ -40,6 +45,20 @@ object TaskFiltering {
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
}
/**
* Whether [task] belongs in Today. An open task does when it is due today, or
* starts today without being overdue; a completed one when it was completed today.
*/
fun isToday(task: Task, todayStart: Instant, todayEnd: Instant, zone: ZoneId = ZoneId.systemDefault()): Boolean {
fun inToday(at: Instant?) = at != null && at >= todayStart && at < todayEnd
if (task.isClosed) return task.isCompleted && inToday(task.completedAt)
return when (dueWindowOf(task, todayStart, todayEnd, zone)) {
DueWindow.TODAY -> true
DueWindow.OVERDUE -> false
DueWindow.UPCOMING, DueWindow.NO_DATE -> inToday(task.wallClock(task.start, zone))
}
}
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
@@ -49,7 +68,7 @@ object TaskFiltering {
SmartList.ALL -> !task.isClosed
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
SmartList.TODAY -> isToday(task, todayStart, todayEnd)
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
}
}
@@ -16,19 +16,22 @@ data class SectionedTasks(val section: TaskSection, val tasks: List<Task>)
*
* [todayStart] is local midnight today and [todayEnd] local midnight tomorrow
* (compute with [DayWindow]). Only non-empty sections are returned, in the
* fixed order Overdue → Today → Upcoming → No date → Completed.
* fixed order Overdue → Today → Upcoming → No date → Completed. In the Today
* list ([todayList]) every open task sits under Today, as it may be there by its
* start date alone.
*/
object TaskSections {
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant): List<SectionedTasks> {
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) }
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): List<SectionedTasks> {
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd, todayList) }
return TaskSection.entries.mapNotNull { section ->
bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) }
}
}
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): TaskSection {
if (task.isCompleted) return TaskSection.COMPLETED
if (todayList) return TaskSection.TODAY
return when (dueWindowOf(task, todayStart, todayEnd)) {
DueWindow.OVERDUE -> TaskSection.OVERDUE
DueWindow.TODAY -> TaskSection.TODAY
@@ -46,6 +46,7 @@ fun RootScreen(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
permissionViewModel: PermissionViewModel = hiltViewModel(),
) {
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
@@ -86,6 +87,7 @@ fun RootScreen(
modifier = modifier,
navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
)
}
}
@@ -101,6 +103,7 @@ private fun ReadyGate(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
onboardingViewModel: OnboardingViewModel = hiltViewModel(),
) {
val done by onboardingViewModel.done.collectAsStateWithLifecycle()
@@ -113,6 +116,7 @@ private fun ReadyGate(
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
)
false -> ReadableWidth(modifier) {
OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel)
@@ -0,0 +1,66 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import kotlin.math.abs
/** How far past the system back-gesture zone a horizontal swipe is still ignored. */
private val EDGE_MARGIN = 24.dp
/**
* Swallows horizontal swipes that start near the screen's left or right edge,
* before a swipe box below it sees them (#90). A back gesture started just
* inside the system's gesture zone otherwise reaches the app as a row swipe.
* Taps and vertical scrolls are left alone.
*/
@Composable
fun Modifier.ignoringEdgeSwipes(): Modifier {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val windowWidth = LocalWindowInfo.current.containerSize.width
val insets = WindowInsets.systemGestures
val margin = with(density) { EDGE_MARGIN.roundToPx() }
val leftEdge = insets.getLeft(density, layoutDirection) + margin
val rightEdge = windowWidth - insets.getRight(density, layoutDirection) - margin
var offsetX by remember { mutableFloatStateOf(0f) }
return this
.onGloballyPositioned { offsetX = it.positionInWindow().x }
.pointerInput(leftEdge, rightEdge) {
val slop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val x = offsetX + down.position.x
if (x > leftEdge && x < rightEdge) return@awaitEachGesture
var horizontal = false
while (true) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes
.firstOrNull { it.id == down.id } ?: break
if (!horizontal) {
val delta = change.position - down.position
when {
abs(delta.x) > slop -> horizontal = true
abs(delta.y) > slop -> break
}
}
if (horizontal) change.consume()
if (!change.pressed) break
}
}
}
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Pull down to sync every account (#73). Without an account there is nothing
* to sync, so the gesture is off. [topPadding] keeps the indicator below a top
* bar the content scrolls under.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SyncPullBox(
available: Boolean,
syncing: Boolean,
onSync: () -> Unit,
modifier: Modifier = Modifier,
topPadding: Dp = 0.dp,
content: @Composable BoxScope.() -> Unit,
) {
if (!available) {
Box(modifier, content = content)
return
}
val state = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = syncing,
onRefresh = onSync,
modifier = modifier,
state = state,
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = state,
isRefreshing = syncing,
modifier = Modifier.align(Alignment.TopCenter).padding(top = topPadding),
)
},
content = content,
)
}
@@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Priority
@@ -109,7 +108,6 @@ data class TaskEditUiState(
class TaskEditViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val reminderScheduler: ReminderScheduler,
) : ViewModel() {
private val _state = MutableStateFlow(TaskEditUiState())
@@ -149,6 +147,7 @@ class TaskEditViewModel @Inject constructor(
private var loadedReminders: List<TaskReminder> = emptyList()
private var loadedStart: Instant? = null
private var loadedDue: Instant? = null
private var loadedAllDay = false
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(
@@ -157,6 +156,7 @@ class TaskEditViewModel @Inject constructor(
initialTitle: String? = null,
presetTag: String? = null,
presetFilterId: Long? = null,
presetDueToday: Boolean = false,
) {
if (bound) return
bound = true
@@ -169,6 +169,7 @@ class TaskEditViewModel @Inject constructor(
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
?.newTaskPreset(LocalDate.now())
val presetDue = preset?.dueDate ?: LocalDate.now().takeIf { presetDueToday }
// 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.
@@ -190,12 +191,12 @@ class TaskEditViewModel @Inject constructor(
// today's start would land after a due of today.
start = when {
!settings.defaultStartToday -> null
preset?.dueDate != null -> allDayInstantOf(LocalDate.now())
presetDue != null -> allDayInstantOf(LocalDate.now())
else -> Clock.System.now()
},
// The same all-day value picking the date in the date field gives.
due = preset?.dueDate?.let(::allDayInstantOf),
isAllDay = preset?.dueDate != null,
due = presetDue?.let(::allDayInstantOf),
isAllDay = presetDue != null,
priority = preset?.priority ?: Priority.NONE,
parentId = parentId,
lists = lists,
@@ -228,6 +229,7 @@ class TaskEditViewModel @Inject constructor(
storedReminders = loadedReminders.toSet()
loadedStart = task.start
loadedDue = task.due
loadedAllDay = task.isAllDay
_state.value = withFields(
TaskEditUiState(
loading = false,
@@ -362,6 +364,7 @@ class TaskEditViewModel @Inject constructor(
toForm().copy(reminders = loadedReminders),
loadedStart,
loadedDue,
loadedAllDay,
settings,
nowMillis(),
ZoneId.systemDefault(),
@@ -446,13 +449,6 @@ class TaskEditViewModel @Inject constructor(
scope = scope,
)
}
// ⚠️ Outside the write's own result. The task is saved by this
// point, and reporting a scheduling failure as a *save* failure
// sends the user back to tap Save again — on a screen whose
// editingTaskId is still null, which creates a second task. The
// reminder is re-synced on the next data change and on launch;
// the duplicate is forever.
runCatching { reminderScheduler.sync() }
}.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
}.onFailure { error ->
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
val result = importer.import(ready.parsed, target)
(chosen as? ImportChoice.Existing)?.list
?.takeIf { it.accountId != null && result.imported > 0 }
?.let { runCatching { syncRequests.syncNow(it.accountName) } }
?.let { runCatching { syncRequests.syncNow(it.accountId!!) } }
ImportStep.Done(
listName = listName,
imported = result.imported,
@@ -97,6 +97,7 @@ import kotlinx.coroutines.delay
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import de.jeanlucmakiola.floret.components.positionOf
@@ -122,6 +123,8 @@ fun ListsScreen(
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
var newList by rememberSaveable { mutableStateOf(false) }
@@ -173,7 +176,12 @@ fun ListsScreen(
}
},
) { inner ->
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
SyncPullBox(
available = syncAvailable && !searchActive,
syncing = syncing,
onSync = viewModel::syncAll,
modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()),
) {
when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure ->
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
@@ -105,9 +106,17 @@ private const val UPCOMING_PREVIEW = 3
class ListsViewModel @Inject constructor(
private val repository: TasksRepository,
private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
settingsPrefs: SettingsPrefs,
) : ViewModel() {
/** Pull to sync (#73); off without an account. */
val syncAvailable: StateFlow<Boolean> =
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncing: StateFlow<Boolean> = manualSync.syncing
fun syncAll() = manualSync.syncAll()
/**
* Where a new list may go: the device, then every account whose server said
* it would take one.
@@ -163,14 +172,12 @@ class ListsViewModel @Inject constructor(
SmartCount(SmartList.ALL, smartTopLevel.size),
)
// Today ring: completed vs. total tasks *due today*. The numerator is the
// top-level completed tasks whose due date falls in today's window; the
// denominator adds the still-open ones (the Today smart count above).
// Today ring: the completed rows of the Today list against all of its rows.
val openToday = count(SmartList.TODAY)
val completedDueToday = completedTasks.count {
!it.isSubtask && it.listId !in hidden && it.due != null && it.due >= todayStart && it.due < todayEnd
val completedToday = completedTasks.count {
!it.isSubtask && it.listId !in hidden && TaskFiltering.isToday(it, todayStart, todayEnd)
}
val todayTotal = openToday + completedDueToday
val todayTotal = openToday + completedToday
// Upcoming preview: the next handful of open tasks due tomorrow onward,
// already sorted by the repository's default ordering.
@@ -205,7 +212,7 @@ class ListsViewModel @Inject constructor(
smartCounts = smartCounts,
filters = filters,
groups = groups,
todayDone = completedDueToday,
todayDone = completedToday,
todayTotal = todayTotal,
upcoming = upcoming,
allTasks = allTasks,
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.ui.navigation
import android.annotation.SuppressLint
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
@@ -7,11 +8,17 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import kotlin.time.Instant
import androidx.navigation.compose.NavHost
@@ -46,8 +53,21 @@ fun AgendulaNavHost(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
) {
val nav = rememberNavController()
// The entry a widget, tile, shortcut or share opened; closing it leaves the app (#93).
var returnHomeEntry by rememberSaveable { mutableStateOf<String?>(null) }
// No public API exposes the whole back stack, and visibleEntries drops an entry covered by another.
@SuppressLint("RestrictedApi")
val backStack by nav.currentBackStack.collectAsState()
LaunchedEffect(backStack, returnHomeEntry) {
val id = returnHomeEntry ?: return@LaunchedEffect
if (backStack.none { it.id == id }) {
returnHomeEntry = null
onReturnHome()
}
}
LaunchedEffect(navRequest) {
when (navRequest) {
@@ -59,7 +79,9 @@ fun AgendulaNavHost(
is NavRequest.OpenAccount ->
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
is NavRequest.NewTask ->
nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
nav.navigate(
Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title, dueToday = navRequest.dueToday),
)
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
launchSingleTop = true
}
@@ -71,6 +93,9 @@ fun AgendulaNavHost(
}
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
}
val returnHome = (navRequest as? NavRequest.NewTask)?.returnHome == true ||
(navRequest as? NavRequest.OpenTask)?.returnHome == true
returnHomeEntry = if (returnHome) nav.currentBackStackEntry?.id else null
onNavRequestConsumed()
}
@@ -141,6 +166,7 @@ fun AgendulaNavHost(
presetListId = (filter as? TaskFilter.OfList)?.listId,
tag = (filter as? TaskFilter.Tagged)?.tag,
filterId = (filter as? TaskFilter.Saved)?.id,
dueToday = filter == TaskFilter.Smart(SmartList.TODAY),
),
)
},
@@ -212,6 +238,7 @@ fun AgendulaNavHost(
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
val dueToday = args?.getBoolean(Dest.TaskEdit.ARG_DUE_TODAY) ?: false
val vm: TaskEditViewModel = hiltViewModel()
LaunchedEffect(taskId, presetListId, parentId) {
if (taskId != Dest.NO_ID) {
@@ -223,6 +250,7 @@ fun AgendulaNavHost(
initialTitle = title,
presetTag = tag,
presetFilterId = filterId,
presetDueToday = dueToday,
)
}
}
@@ -119,7 +119,7 @@ object Dest {
/**
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
* task, `presetListId` / `parentId` optionally seed the form, and `filterId`
* fills it so the task lands in that saved filter.
* (or `dueToday`, for the Today list) fills it so the task lands in that view.
*/
object TaskEdit {
const val ARG_TASK_ID = "taskId"
@@ -129,6 +129,7 @@ object Dest {
const val ARG_TITLE = "title"
const val ARG_TAG = "tag"
const val ARG_FILTER_ID = "filterId"
const val ARG_DUE_TODAY = "dueToday"
const val route =
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
@@ -136,7 +137,8 @@ object Dest {
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
"&$ARG_TITLE={$ARG_TITLE}" +
"&$ARG_TAG={$ARG_TAG}" +
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
"&$ARG_FILTER_ID={$ARG_FILTER_ID}" +
"&$ARG_DUE_TODAY={$ARG_DUE_TODAY}"
val arguments = listOf(
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
@@ -146,6 +148,7 @@ object Dest {
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_DUE_TODAY) { type = NavType.BoolType; defaultValue = false },
)
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
@@ -158,6 +161,7 @@ object Dest {
title: String? = null,
tag: String? = null,
filterId: Long? = null,
dueToday: Boolean = false,
): String {
val params = buildList {
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
@@ -165,6 +169,7 @@ object Dest {
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
if (filterId != null) add("$ARG_FILTER_ID=$filterId")
if (dueToday) add("$ARG_DUE_TODAY=true")
}
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
}
@@ -9,8 +9,15 @@ import de.jeanlucmakiola.agendula.domain.SmartList
* [AgendulaNavHost], which pushes it over whatever is showing.
*/
sealed interface NavRequest {
/** One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. */
data class OpenTask(val taskId: Long, val occurrenceStart: Long? = null) : NavRequest
/**
* One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series.
* [returnHome] leaves the app once the detail is closed (a widget row, #93).
*/
data class OpenTask(
val taskId: Long,
val occurrenceStart: Long? = null,
val returnHome: Boolean = false,
) : NavRequest
/** Settings → Accounts, where a sync report's detail lives. */
data object OpenAccounts : NavRequest
@@ -20,9 +27,16 @@ sealed interface 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.
* preset to a list — a widget's own add button, when it shows one list —
* or due today, from a Today widget.
* [returnHome] leaves the app once the form is closed (#93).
*/
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
data class NewTask(
val title: String? = null,
val listId: Long? = null,
val dueToday: Boolean = false,
val returnHome: Boolean = false,
) : NavRequest
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
data class OpenSmart(val list: SmartList) : NavRequest
@@ -12,16 +12,23 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.appname.LauncherName
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.LauncherMark
import de.jeanlucmakiola.floret.components.LauncherNameChooser
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -38,6 +45,8 @@ internal fun AppearanceScreen(
) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) }
var showAppName by remember { mutableStateOf(false) }
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
var showTimeFormat by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
@@ -90,6 +99,36 @@ internal fun AppearanceScreen(
position = Position.Bottom,
onClick = { showWeekStart = true },
)
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_app_name),
summary = launcherNameLabel(launcherName),
position = Position.Alone,
onClick = { showAppName = true },
)
}
if (showAppName) {
FullScreenPicker(
title = stringResource(R.string.settings_app_name),
onDismiss = { showAppName = false },
predictiveBack = true,
) {
LauncherNameChooser(
summary = stringResource(R.string.settings_app_name_hint),
options = LauncherName.entries,
selected = launcherName,
label = { launcherNameLabel(it) },
onSelect = viewModel::setLauncherName,
mark = {
LauncherMark(
background = colorResource(R.color.ic_launcher_background),
foreground = painterResource(R.drawable.ic_launcher_foreground),
)
},
)
}
}
if (showTimeFormat) {
@@ -140,6 +179,7 @@ internal fun AppearanceScreen(
options = ThemeMode.entries,
selected = state.settings.themeMode,
label = { stringResource(themeLabel(it)) },
summary = { if (it == ThemeMode.BLACK) stringResource(R.string.settings_theme_black_summary) else null },
onSelect = viewModel::setThemeMode,
onDismiss = { showTheme = false },
)
@@ -163,8 +203,17 @@ private fun weekStartLabel(day: DayOfWeek?): String =
/** 14:00, the sample time "Automatic" is shown with. */
private const val SAMPLE_MINUTE_OF_DAY = 14 * 60
@Composable
private fun launcherNameLabel(name: LauncherName): String = stringResource(
when (name) {
LauncherName.AGENDULA -> R.string.app_name
LauncherName.TASKS -> R.string.app_name_tasks_alias
},
)
private fun themeLabel(mode: ThemeMode): Int = when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
ThemeMode.BLACK -> R.string.settings_theme_black
}
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.appname.LauncherName
import de.jeanlucmakiola.agendula.data.appname.LauncherNameManager
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
@@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.DayOfWeek
import javax.inject.Inject
@@ -102,7 +105,8 @@ class SettingsViewModel @Inject constructor(
private val oneShotImport: OneShotImport,
private val reminderScheduler: ReminderScheduler,
private val taskNotifier: TaskNotifier,
@IoDispatcher io: CoroutineDispatcher,
private val launcherNameManager: LauncherNameManager,
@IoDispatcher private val io: CoroutineDispatcher,
repository: TasksRepository,
) : ViewModel() {
@@ -184,7 +188,6 @@ class SettingsViewModel @Inject constructor(
transferState.update { it.copy(confirming = false, running = true, outcome = null) }
viewModelScope.launch {
val result = externalImport.run()
if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() }
transferState.update {
it.copy(
running = false,
@@ -208,7 +211,6 @@ class SettingsViewModel @Inject constructor(
retryingLegacyImport.value = true
viewModelScope.launch {
runCatching { oneShotImport.reimportFromArchive() }
runCatching { reminderScheduler.sync() }
retryingLegacyImport.value = false
}
}
@@ -217,6 +219,23 @@ class SettingsViewModel @Inject constructor(
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport
private val _launcherName = MutableStateFlow(LauncherName.AGENDULA)
val launcherName: StateFlow<LauncherName> = _launcherName
init {
viewModelScope.launch { _launcherName.value = withContext(io) { launcherNameManager.current() } }
}
fun setLauncherName(name: LauncherName) {
_launcherName.value = name
viewModelScope.launch {
_launcherName.value = withContext(io) {
launcherNameManager.set(name)
launcherNameManager.current()
}
}
}
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
@@ -112,6 +112,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
import de.jeanlucmakiola.agendula.ui.common.ignoringEdgeSwipes
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
@@ -144,6 +146,8 @@ fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val content = state as? TaskListUiState.Content
val list = content?.list
@@ -247,13 +251,21 @@ fun TaskListScreen(
},
) { inner ->
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
)
SyncPullBox(
available = syncAvailable,
syncing = syncing,
onSync = viewModel::syncAll,
modifier = Modifier.fillMaxSize(),
topPadding = inner.calculateTopPadding(),
) {
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
)
}
}
// A FAB-height band anchored at the bottom-start with the FAB's own
// margin; centring the chip in it lines the chip up beside the bottom-end
@@ -355,7 +367,7 @@ private fun TaskListBody(
viewModel: TaskListViewModel,
onClearCompleted: (() -> Unit)? = null,
) {
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
val (todayStart, todayEnd) = remember(state.tasks) { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
// A parent represents its children via a progress chip (counts come from the
// repository, over the full set), so drop children from the flat list — unless
// their parent isn't in this view (can happen in smart lists), where we keep
@@ -364,15 +376,15 @@ private fun TaskListBody(
val present = state.tasks.mapTo(HashSet()) { it.taskId }
state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
}
val sections = remember(displayTasks) { TaskSections.of(displayTasks, todayStart, todayEnd) }
val sections = remember(displayTasks, todayStart) {
TaskSections.of(displayTasks, todayStart, todayEnd, todayList = filter == TaskFilter.Smart(SmartList.TODAY))
}
val showHeaders = sections.size > 1
val listId = (filter as? TaskFilter.OfList)?.listId
// In a mixed view (a smart list) the list colour bar + name disambiguate
// rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are
// grouped from the same flat task set the list already holds.
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
@@ -420,23 +432,20 @@ private fun TaskListBody(
}
sections.forEach { section ->
val collapsible = showHeaders && section.section == TaskSection.COMPLETED
// Every header folds its section away (#91); Done starts folded.
val expanded = !showHeaders || section.section !in state.collapsedSections
if (showHeaders) {
stickyHeader(key = "hdr-${section.section}") {
if (collapsible) {
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = completedExpanded,
onToggle = { completedExpanded = !completedExpanded },
onClear = onClearCompleted,
)
} else {
SectionHeader(sectionLabel(section.section))
}
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = expanded,
onToggle = { viewModel.setSectionCollapsed(section.section, expanded) },
onClear = onClearCompleted.takeIf { section.section == TaskSection.COMPLETED },
)
}
}
if (!collapsible || completedExpanded) {
if (expanded) {
// Flatten the section into one grouped run: each parent, followed
// by its subtasks when expanded. Each row's corners then come from
// its place in the whole run (positionOf below) — so the children
@@ -655,7 +664,7 @@ private fun TaskRow(
SwipeToDismissBox(
state = dismissState,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
// dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
@@ -1037,7 +1046,7 @@ private fun SubtaskRow(
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
SwipeToDismissBox(
state = dismissState,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
) {
Surface(
@@ -1127,19 +1136,6 @@ private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, sma
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
}
@Composable
private fun SectionHeader(text: String) {
// Opaque surface so rows scroll cleanly under the pinned header.
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/** A tappable section header with a count and a chevron that rotates on expand. */
@Composable
private fun CollapsibleSectionHeader(
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_COLLAPSED_SECTIONS
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
@@ -14,11 +15,13 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.tagsInUse
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.agendula.domain.comparator
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
import de.jeanlucmakiola.agendula.ui.lists.asFailure
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -56,6 +59,8 @@ sealed interface TaskListUiState {
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false,
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
/** Sections whose rows are folded under their header. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
/** This real list is kept out of the smart lists. */
val hiddenFromSmartLists: Boolean = false,
/** Tags the unnarrowed view carries, offered as filter chips. */
@@ -89,9 +94,17 @@ class TaskListViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
@ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
/** Pull to sync (#73); off without an account. */
val syncAvailable: StateFlow<Boolean> =
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncing: StateFlow<Boolean> = manualSync.syncing
fun syncAll() = manualSync.syncAll()
private val filter = MutableStateFlow<TaskFilter?>(null)
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
@@ -144,6 +157,7 @@ class TaskListViewModel @Inject constructor(
showAddSubtaskRow = settings.showAddSubtaskRow,
bottomAddBar = settings.bottomAddBar,
sortOrder = settings.sortOrder,
collapsedSections = settings.collapsedSections,
hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false,
)
} else {
@@ -277,6 +291,9 @@ class TaskListViewModel @Inject constructor(
fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) =
viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) }
fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) =
viewModelScope.launch { settingsPrefs.setSectionCollapsed(section, collapsed) }
fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) }
/**
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.agendula.ui.theme
import android.app.Activity
import android.app.UiModeManager
import android.os.Build
import androidx.annotation.StyleRes
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
/**
* Hands the app's theme setting to the system, which draws the launch window
* and splash before any of the app runs (#119). The system keeps both across
* launches, so the next start already matches.
*/
object SystemTheme {
/** The light or dark the app's resources follow, as [UiModeManager.setApplicationNightMode] takes it. */
fun nightModeOf(mode: ThemeMode): Int = when (mode) {
ThemeMode.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO
ThemeMode.LIGHT -> UiModeManager.MODE_NIGHT_NO
ThemeMode.DARK, ThemeMode.BLACK -> UiModeManager.MODE_NIGHT_YES
}
/** The splash theme whose background is the one the app opens on. */
@StyleRes
fun splashThemeOf(mode: ThemeMode, dynamicColor: Boolean): Int = when {
mode.isPureBlack -> R.style.Theme_Agendula_Black
dynamicColor -> R.style.Theme_Agendula
else -> R.style.Theme_Agendula_Fallback
}
/**
* Applies [mode] and [dynamicColor]. A night mode the activity does not
* have yet recreates it, once; the same one again changes nothing.
*/
fun apply(activity: Activity, mode: ThemeMode, dynamicColor: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
activity.getSystemService(UiModeManager::class.java)?.setApplicationNightMode(nightModeOf(mode))
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.splashScreen.setSplashScreenTheme(splashThemeOf(mode, dynamicColor))
}
}
}
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
fun AgendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
pureBlack: Boolean = false,
content: @Composable () -> Unit,
) {
FloretExpressiveTheme(
@@ -22,6 +23,7 @@ fun AgendulaTheme(
darkScheme = AgendulaDarkFallback,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
pureBlack = pureBlack,
typography = AgendulaTypography,
content = content,
)
@@ -194,10 +194,12 @@ private fun TaskWidgetBody(
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
} else {
val todayList = filter == TaskFilter.Smart(SmartList.TODAY)
val allSections = widgetSections(
data.rows,
data.groupByList,
data.listNames,
todayList,
) { 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
@@ -248,7 +250,8 @@ private fun TaskWidgetBody(
// (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,
suppressDayLabel = !data.groupByList &&
(!todayList || section.row.dueKind == DueKind.TODAY),
)
Spacer(GlanceModifier.height(8.dp))
}
@@ -304,7 +307,9 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
WidgetHeaderAction(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
action = actionStartActivity(
MainActivity.newTaskIntent(context, singleListId, dueToday = filter == TaskFilter.Smart(SmartList.TODAY)),
),
)
}
}
@@ -328,7 +333,7 @@ internal fun AgendaTaskRow(
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()),
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true),
)
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
@@ -39,7 +39,6 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
@@ -135,12 +134,12 @@ class TaskWidgetConfigActivity : ComponentActivity() {
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
}
val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
val dark = settings.themeMode.isDark(isSystemInDarkTheme())
AgendulaTheme(
darkTheme = dark,
dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode.isPureBlack,
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
WidgetFilterPicker(
kind = kind,
@@ -3,14 +3,11 @@ package de.jeanlucmakiola.agendula.widget
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
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
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@@ -26,16 +23,10 @@ class TaskWidgetUpdater @Inject constructor(
private val dataSource: TasksDataSource,
@ApplicationScope private val scope: CoroutineScope,
) {
private val requests = Channel<Unit>(Channel.CONFLATED)
private var observer: AutoCloseable? = null
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerObserver, ::redraw)
@OptIn(FlowPreview::class)
fun start() {
if (observer != null) return
observer = runCatching { dataSource.registerObserver { requests.trySend(Unit) } }.getOrNull()
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
}
if (!changes.start()) return
scope.launch(Dispatchers.IO) {
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
}
@@ -44,9 +35,7 @@ class TaskWidgetUpdater @Inject constructor(
}
/** Redraw soon; bursts collapse into one pass. */
fun requestRefresh() {
requests.trySend(Unit)
}
fun requestRefresh() = changes.request()
private suspend fun redraw() {
runCatching {
@@ -100,7 +100,7 @@ private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground
WidgetHeaderAction(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context)),
action = actionStartActivity(MainActivity.newTaskIntent(context, dueToday = true)),
)
}
Spacer(GlanceModifier.height(HeaderGap))
@@ -339,7 +339,7 @@ private fun ListDot(color: Int) {
}
private fun openTask(context: Context, row: WidgetTaskRow): Action =
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()))
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true))
private fun completeTask(row: WidgetTaskRow): Action =
actionRunCallback<CompleteTaskAction>(
@@ -82,7 +82,7 @@ internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMo
override ?: when (appThemeMode) {
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
ThemeMode.DARK -> WidgetThemeMode.DARK
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
}
/** The smart lists a widget can show, in picker order. */
@@ -200,12 +200,14 @@ private fun headedRuns(runs: List<WidgetRun>): List<WidgetSection> =
* (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.
* followed by its own standalone, independently coloured task pills. The Today
* list ([todayList]) is one "Today" bucket, as a row may be there by its start.
*/
internal fun widgetSections(
rows: List<WidgetTaskRow>,
groupByList: Boolean,
listNames: Map<Long, String>,
todayList: Boolean = false,
dueBucketLabel: (DueKind?) -> String,
): List<WidgetSection> {
if (groupByList) {
@@ -220,7 +222,7 @@ internal fun widgetSections(
}
return headedRuns(runs)
}
val buckets = rows.groupBy { it.dueKind }
val buckets = rows.groupBy { if (todayList) DueKind.TODAY else it.dueKind }
val runs = DueKind.entries.plus(null).mapNotNull { kind ->
buckets[kind]?.takeIf { it.isNotEmpty() }
?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) }
@@ -7,9 +7,11 @@ 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.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.domain.comparator
import de.jeanlucmakiola.floret.time.DayWindow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -45,10 +47,15 @@ internal sealed interface WidgetStatsData {
private const val MAX_TODAY_ROWS = 20
/** Whether this open task is on the Today widget's plate: in the app's Today, or overdue. */
private fun Task.isDueToday(now: Instant, today: LocalDate, todayStart: Instant, todayEnd: Instant, zone: ZoneId): Boolean =
TaskFiltering.isToday(this, todayStart, todayEnd, zone) ||
due?.let { isOverdue(it, isAllDay, now, today, zone) } == true
/**
* 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.
* the app's Today tasks done and still open ([TaskFiltering.isToday]) plus the
* overdue ones, the open ones in [comparator] order, and those tasks as rows.
*/
internal fun todayProgress(
open: List<Task>,
@@ -58,14 +65,9 @@ internal fun todayProgress(
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)
val (todayStart, todayEnd) = DayWindow.today(now, zone)
val done = completed.count { TaskFiltering.isToday(it, todayStart, todayEnd, zone) }
val remaining = open.filter { it.isDueToday(now, today, todayStart, todayEnd, zone) }.sortedWith(comparator)
return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) })
}
@@ -76,29 +78,29 @@ internal fun upcoming(open: List<Task>, now: Instant, today: LocalDate, zone: Zo
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.
* Seven [WeekDay]s starting at [today]. Today's bucket holds what the Today
* widget counts — the app's Today tasks plus every overdue one; every other day
* holds the rest by their own due date.
*/
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> =
(0..6L).map { offset ->
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> {
val (todayStart, todayEnd) = DayWindow.today(now, zone)
val (todays, later) = open.partition { it.isDueToday(now, today, todayStart, todayEnd, zone) }
return (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 dayTasks = if (offset == 0L) {
todays
} else {
later.filter { task -> task.due?.calendarDate(task.isAllDay, zone) == date }
}
val overdueCount = if (offset == 0L) {
dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) }
dayTasks.count { task -> task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } == true }
} 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> {