diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c00359..6699fb7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
## [Unreleased]
### Added
+- M1 data layer + logic ("backoffice"): the full non-visual stack over the
+ OpenTasks `TaskContract` provider (authority resolved at runtime —
+ `org.tasks.opentasks` for tasks.org, `org.dmfs.tasks` for OpenTasks).
+ - Vendored `TaskContract` subset, `ProviderResolver`, `ColumnReader`, mappers,
+ `AndroidTasksDataSource` (Instances query + ContentObserver), and
+ `TasksRepository` exposing live Flows of lists/tasks/detail.
+ - Domain models, smart-list filtering (Today/Upcoming/Overdue/No-date/All/
+ Completed), sorting, form validation, sub-tasks via `parent_id`.
+ - Self-scheduled due-reminder engine (AlarmManager + boot/provider-change
+ re-sync), notifications, DataStore prefs.
+ - Render-only ViewModels + UiState for every screen (lists, task list, detail,
+ edit, settings, permission) so the UI is build-only.
+ - 24 JVM unit tests (mappers, filtering, sorting, form, value mapping, day
+ windows). `RootScreen` is a functional scaffold over the real data, to be
+ replaced screen-by-screen with the Material 3 Expressive design.
- M0 skeleton: project scaffolding copied from Calendula (Gradle, version
catalog, Hilt, Material 3 Expressive theme, Gitea CI/release workflows),
reseeded to a warm-mauve palette. Builds and shows a themed placeholder.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1135266..a540cfb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,13 +2,32 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/FloretApp.kt b/app/src/main/java/de/jeanlucmakiola/floret/FloretApp.kt
index 4a2807d..98f5f18 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/FloretApp.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/FloretApp.kt
@@ -1,11 +1,38 @@
package de.jeanlucmakiola.floret
import android.app.Application
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
+import dagger.hilt.components.SingletonComponent
+import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
/**
- * Application entry point. Registered as android:name=".FloretApp"
- * in AndroidManifest.xml. Hilt initializes its component graph here.
+ * Application entry point. Registered as android:name=".FloretApp". Besides
+ * Hilt init, it kicks off a reminder re-sync on launch (independent of any UI),
+ * so alarms reflect tasks synced while the app was closed.
*/
@HiltAndroidApp
-class FloretApp : Application()
+class FloretApp : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ val scheduler = EntryPointAccessors
+ .fromApplication(this, ReminderEntryPoint::class.java)
+ .reminderScheduler()
+ CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
+ runCatching { scheduler.sync() }
+ }
+ }
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface ReminderEntryPoint {
+ fun reminderScheduler(): ReminderScheduler
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/floret/MainActivity.kt
index ef6e274..9443109 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/MainActivity.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/MainActivity.kt
@@ -1,19 +1,27 @@
package de.jeanlucmakiola.floret
+import android.content.Context
+import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.floret.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.ui.RootScreen
+import de.jeanlucmakiola.floret.ui.settings.SettingsViewModel
import de.jeanlucmakiola.floret.ui.theme.FloretTheme
/**
- * Single activity. M0 hosts only a themed placeholder scaffold; the task
- * screens, theme-mode wiring (SettingsViewModel) and notification/widget
- * intent routing land in later milestones — mirroring Calendula's MainActivity.
+ * Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
+ * (replaceable) functional scaffold over the real data layer. Task-detail intent
+ * routing for reminder taps lands with the full UI.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -22,9 +30,27 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
- FloretTheme {
+ 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
+ }
+ FloretTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
RootScreen(modifier = Modifier.fillMaxSize())
}
}
}
+
+ companion object {
+ const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID"
+
+ /** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
+ fun taskIntent(context: Context, taskId: Long): Intent =
+ Intent(context, MainActivity::class.java).apply {
+ putExtra(EXTRA_TASK_ID, taskId)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ }
}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/di/DataModule.kt
new file mode 100644
index 0000000..251f76e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/di/DataModule.kt
@@ -0,0 +1,50 @@
+package de.jeanlucmakiola.floret.data.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import de.jeanlucmakiola.floret.data.tasks.AndroidTasksDataSource
+import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
+import de.jeanlucmakiola.floret.data.tasks.TasksRepository
+import de.jeanlucmakiola.floret.data.tasks.TasksRepositoryImpl
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import javax.inject.Singleton
+
+private val Context.floretDataStore: DataStore by preferencesDataStore(
+ name = "floret_prefs",
+)
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class DataBindModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataProvideModule {
+
+ @Provides
+ @Singleton
+ fun provideDataStore(@ApplicationContext context: Context): DataStore =
+ context.floretDataStore
+
+ @Provides
+ @IoDispatcher
+ fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/di/Qualifiers.kt
new file mode 100644
index 0000000..509478d
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/di/Qualifiers.kt
@@ -0,0 +1,8 @@
+package de.jeanlucmakiola.floret.data.di
+
+import javax.inject.Qualifier
+
+/** Marks the IO [kotlinx.coroutines.CoroutineDispatcher] for provider access. */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class IoDispatcher
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt
new file mode 100644
index 0000000..051caa3
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt
@@ -0,0 +1,55 @@
+package de.jeanlucmakiola.floret.data.prefs
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+enum class ThemeMode { SYSTEM, LIGHT, DARK }
+
+data class Settings(
+ val themeMode: ThemeMode = ThemeMode.SYSTEM,
+ val dynamicColor: Boolean = true,
+ /** The list a new task defaults to; `null` = first available. */
+ val defaultListId: Long? = null,
+ /** Default minutes before due to remind; 0 = at due time. */
+ val reminderLeadMinutes: Int = 0,
+)
+
+/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
+@Singleton
+class SettingsPrefs @Inject constructor(
+ private val dataStore: DataStore,
+) {
+ val settings: Flow = dataStore.data.map { p ->
+ Settings(
+ themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
+ ?: ThemeMode.SYSTEM,
+ dynamicColor = p[DYNAMIC_COLOR] ?: true,
+ defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
+ reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
+ )
+ }
+
+ suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
+ suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
+ suspend fun setDefaultListId(id: Long?) = dataStore.edit {
+ if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
+ }
+
+ suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
+
+ private companion object {
+ val THEME_MODE = stringPreferencesKey("theme_mode")
+ val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
+ val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
+ val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/BootReceiver.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/BootReceiver.kt
new file mode 100644
index 0000000..dc93b62
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/BootReceiver.kt
@@ -0,0 +1,32 @@
+package de.jeanlucmakiola.floret.data.reminders
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/** Re-arms all reminder alarms after a reboot (alarms don't survive it). */
+@AndroidEntryPoint
+class BootReceiver : BroadcastReceiver() {
+
+ @Inject lateinit var scheduler: ReminderScheduler
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
+ val pending = goAsync()
+ scope.launch {
+ try {
+ scheduler.sync()
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/DueReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/DueReminderReceiver.kt
new file mode 100644
index 0000000..5307a31
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/DueReminderReceiver.kt
@@ -0,0 +1,47 @@
+package de.jeanlucmakiola.floret.data.reminders
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Fires when a task's reminder alarm goes off. Re-reads the task (it may have
+ * been completed or rescheduled since the alarm was set) and posts only if it's
+ * still open.
+ */
+@AndroidEntryPoint
+class DueReminderReceiver : BroadcastReceiver() {
+
+ @Inject lateinit var dataSource: TasksDataSource
+ @Inject lateinit var notifier: TaskNotifier
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L)
+ if (taskId < 0L) return
+ val pending = goAsync()
+ scope.launch {
+ try {
+ val task = runCatching { dataSource.task(taskId) }.getOrNull()
+ if (task != null && !task.isClosed) notifier.postDue(task)
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+
+ companion object {
+ private const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID"
+
+ fun intent(context: Context, taskId: Long): Intent =
+ Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ProviderChangeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ProviderChangeReceiver.kt
new file mode 100644
index 0000000..f97886e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ProviderChangeReceiver.kt
@@ -0,0 +1,36 @@
+package de.jeanlucmakiola.floret.data.reminders
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Re-syncs reminders whenever the tasks provider changes — covers external sync
+ * (DAVx5 pulling new/edited tasks) while Floret isn't in the foreground. The
+ * manifest filter targets both known authorities; best-effort (the in-app
+ * ContentObserver covers the foreground case regardless).
+ */
+@AndroidEntryPoint
+class ProviderChangeReceiver : BroadcastReceiver() {
+
+ @Inject lateinit var scheduler: ReminderScheduler
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val pending = goAsync()
+ scope.launch {
+ try {
+ scheduler.sync()
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ReminderScheduler.kt
new file mode 100644
index 0000000..9952afc
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ReminderScheduler.kt
@@ -0,0 +1,93 @@
+package de.jeanlucmakiola.floret.data.reminders
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.os.Build
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.floret.data.di.IoDispatcher
+import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.floret.data.tasks.ProviderResolver
+import de.jeanlucmakiola.floret.data.tasks.TaskQuery
+import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The self-scheduled due-reminder engine. Tasks providers don't deliver
+ * reminders, so Floret reads upcoming due tasks and arms one exact [AlarmManager]
+ * alarm each, within a rolling window. Re-run on app start, boot and provider
+ * change; it diffs against [ScheduledReminderStore] so only changed alarms move.
+ */
+@Singleton
+class ReminderScheduler @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val dataSource: TasksDataSource,
+ private val settingsPrefs: SettingsPrefs,
+ private val store: ScheduledReminderStore,
+ private val providerResolver: ProviderResolver,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) {
+ suspend fun sync() = withContext(io) {
+ val provider = providerResolver.resolve()
+ if (provider == null || !providerResolver.hasPermission(provider)) {
+ clearAll()
+ return@withContext
+ }
+ val lead = settingsPrefs.settings.first().reminderLeadMinutes.coerceAtLeast(0)
+ val now = System.currentTimeMillis()
+ val horizon = now + WINDOW_MS
+ val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
+ .getOrElse { return@withContext }
+
+ val desired = tasks
+ .filter { !it.isClosed && it.due != null }
+ .associate { it.taskId to (it.due!!.toEpochMilliseconds() - lead * 60_000L) }
+ .filterValues { it in now..horizon }
+
+ val previous = store.all()
+ (previous.keys - desired.keys).forEach { cancel(it) }
+ desired.forEach { (taskId, triggerAt) ->
+ if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
+ }
+ store.replace(desired)
+ }
+
+ private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
+
+ private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? {
+ val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
+ PendingIntent.FLAG_IMMUTABLE
+ return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags)
+ }
+
+ private fun schedule(taskId: Long, triggerAt: Long) {
+ val pi = pendingIntent(taskId, create = true) ?: return
+ val am = alarmManager()
+ val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
+ if (canExact) {
+ am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
+ } else {
+ am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi)
+ }
+ }
+
+ private fun cancel(taskId: Long) {
+ pendingIntent(taskId, create = false)?.let {
+ alarmManager().cancel(it)
+ it.cancel()
+ }
+ }
+
+ private suspend fun clearAll() {
+ store.all().keys.forEach { cancel(it) }
+ store.replace(emptyMap())
+ }
+
+ private companion object {
+ const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ScheduledReminderStore.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ScheduledReminderStore.kt
new file mode 100644
index 0000000..f903cb1
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ScheduledReminderStore.kt
@@ -0,0 +1,37 @@
+package de.jeanlucmakiola.floret.data.reminders
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Remembers which task reminders are currently scheduled (taskId → trigger time),
+ * so [ReminderScheduler] can diff against a fresh computation and cancel only the
+ * alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
+ */
+@Singleton
+class ScheduledReminderStore @Inject constructor(
+ private val dataStore: DataStore,
+) {
+ suspend fun all(): Map =
+ dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
+ val parts = entry.split('|')
+ val id = parts.getOrNull(0)?.toLongOrNull()
+ val at = parts.getOrNull(1)?.toLongOrNull()
+ if (id != null && at != null) id to at else null
+ }.toMap()
+
+ suspend fun replace(scheduled: Map) {
+ dataStore.edit { prefs ->
+ prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
+ }
+ }
+
+ private companion object {
+ val KEY = stringSetPreferencesKey("scheduled_reminders")
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/TaskNotifier.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/TaskNotifier.kt
new file mode 100644
index 0000000..c800ddc
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/TaskNotifier.kt
@@ -0,0 +1,98 @@
+package de.jeanlucmakiola.floret.data.reminders
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import de.jeanlucmakiola.floret.MainActivity
+import de.jeanlucmakiola.floret.R
+import de.jeanlucmakiola.floret.domain.Task
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.time.Instant as JInstant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Posts one notification per due task on a dedicated channel. The tag is the
+ * task id, so a re-fired alarm replaces rather than duplicates. Tapping opens
+ * the app (later: the task's detail screen — see [MainActivity]).
+ */
+@Singleton
+class TaskNotifier @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ fun canPost(): Boolean {
+ val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
+ PackageManager.PERMISSION_GRANTED
+ return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
+ }
+
+ // canPost() checks POST_NOTIFICATIONS before we ever call notify().
+ @SuppressLint("MissingPermission")
+ fun postDue(task: Task) {
+ if (!canPost()) return
+ ensureChannel()
+ val title = task.title.ifBlank { context.getString(R.string.task_untitled) }
+ val text = task.due?.let { due ->
+ context.getString(R.string.reminder_due_at, formatDue(due.toEpochMilliseconds(), task.isAllDay))
+ }
+ val tapIntent = PendingIntent.getActivity(
+ context,
+ task.taskId.toInt(),
+ MainActivity.taskIntent(context, task.taskId),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(title)
+ .apply { if (text != null) setContentText(text) }
+ .setCategory(NotificationCompat.CATEGORY_REMINDER)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setAutoCancel(true)
+ .setContentIntent(tapIntent)
+ .build()
+ NotificationManagerCompat.from(context).notify(task.taskId.toString(), NOTIFICATION_ID, notification)
+ }
+
+ private fun formatDue(millis: Long, allDay: Boolean): String {
+ val zone = ZoneId.systemDefault()
+ val style = if (allDay) {
+ DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
+ } else {
+ DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
+ }
+ return JInstant.ofEpochMilli(millis).atZone(zone)
+ .format(style.withLocale(Locale.getDefault()))
+ }
+
+ private fun ensureChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = context.getSystemService(NotificationManager::class.java)
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) return
+ manager.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.reminder_channel_name),
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply { description = context.getString(R.string.reminder_channel_desc) },
+ )
+ }
+
+ private companion object {
+ const val CHANNEL_ID = "task_reminders"
+ const val NOTIFICATION_ID = 1
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/AndroidTasksDataSource.kt
new file mode 100644
index 0000000..5708e1d
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/AndroidTasksDataSource.kt
@@ -0,0 +1,142 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.content.ContentValues
+import android.content.Context
+import android.database.ContentObserver
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskForm
+import de.jeanlucmakiola.floret.domain.TaskList
+import java.time.ZoneId
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The only class that knows about the ContentResolver, [TasksContract] and the
+ * active authority. Everything else works through [TasksDataSource] / domain
+ * models, so swapping the provider (Posture B) never reaches above this file.
+ */
+@Singleton
+class AndroidTasksDataSource @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val providerResolver: ProviderResolver,
+) : TasksDataSource {
+
+ private val resolver: ContentResolver get() = context.contentResolver
+
+ private fun authority(): String =
+ providerResolver.resolve()?.authority ?: throw ProviderUnavailableException()
+
+ private fun taskUri(authority: String, taskId: Long): Uri =
+ ContentUris.withAppendedId(TasksContract.tasksUri(authority), taskId)
+
+ // --- reads ----------------------------------------------------------------
+
+ override fun taskLists(): List {
+ val uri = TasksContract.listsUri(authority())
+ val sort = "${Lists.ACCOUNT_NAME}, ${Lists.NAME}"
+ return resolver.query(uri, TaskProjections.LISTS, null, null, sort)?.use { c ->
+ val reader = CursorColumnReader(c)
+ buildList { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
+ } ?: emptyList()
+ }
+
+ override fun tasks(query: TaskQuery): List {
+ val clauses = mutableListOf()
+ val args = mutableListOf()
+ query.listId?.let {
+ clauses += "${Tasks.LIST_ID} = ?"
+ args += it.toString()
+ }
+ if (!query.includeCompleted) clauses += "${Tasks.IS_CLOSED} = 0"
+ val selection = clauses.takeIf { it.isNotEmpty() }?.joinToString(" AND ")
+ return queryInstances(selection, args.takeIf { it.isNotEmpty() }?.toTypedArray())
+ }
+
+ override fun task(taskId: Long): Task? {
+ val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
+ return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull()
+ }
+
+ override fun subtasks(parentTaskId: Long): List =
+ queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
+
+ private fun queryInstances(selection: String?, args: Array?): List {
+ val uri = TasksContract.instancesUri(authority())
+ return resolver.query(uri, TaskProjections.INSTANCES, selection, args, Instances.INSTANCE_DUE_SORTING)
+ ?.use { c ->
+ val reader = CursorColumnReader(c)
+ buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
+ } ?: emptyList()
+ }
+
+ // --- writes ---------------------------------------------------------------
+
+ override fun insertTask(form: TaskForm): Long {
+ val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
+ val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
+ ?: throw TaskWriteFailedException("insert task")
+ return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
+ }
+
+ override fun updateTask(taskId: Long, form: TaskForm) {
+ val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
+ val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
+ if (rows == 0) throw TaskWriteFailedException("update task $taskId")
+ }
+
+ override fun setCompleted(taskId: Long, completed: Boolean) {
+ val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
+ val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
+ if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
+ }
+
+ override fun deleteTask(taskId: Long) {
+ resolver.delete(taskUri(authority(), taskId), null, null)
+ }
+
+ override fun createLocalList(name: String, color: Int): Long {
+ val uri = TasksContract.asSyncAdapter(
+ TasksContract.listsUri(authority()),
+ TasksContract.LOCAL_ACCOUNT_NAME,
+ TasksContract.LOCAL_ACCOUNT_TYPE,
+ )
+ val values = TaskWriteMapper.localListValues(name, color)
+ val result = resolver.insert(uri, values.toContentValues())
+ ?: throw TaskWriteFailedException("create local list")
+ return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id")
+ }
+
+ // --- observation ----------------------------------------------------------
+
+ override fun registerObserver(onChange: () -> Unit): AutoCloseable {
+ val provider = providerResolver.resolve() ?: return AutoCloseable { }
+ val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
+ override fun onChange(selfChange: Boolean) = onChange()
+ }
+ resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
+ resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
+ return AutoCloseable { resolver.unregisterContentObserver(observer) }
+ }
+
+ private fun Map.toContentValues(): ContentValues {
+ val cv = ContentValues(size)
+ for ((key, value) in this) when (value) {
+ null -> cv.putNull(key)
+ is Long -> cv.put(key, value)
+ is Int -> cv.put(key, value)
+ is Boolean -> cv.put(key, if (value) 1 else 0)
+ is String -> cv.put(key, value)
+ else -> cv.put(key, value.toString())
+ }
+ return cv
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/ColumnReader.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/ColumnReader.kt
new file mode 100644
index 0000000..0da8dfd
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/ColumnReader.kt
@@ -0,0 +1,40 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import android.database.Cursor
+
+/**
+ * Domain-shaped seam over a row of provider data, so the mappers can be
+ * unit-tested with a Map instead of a real [Cursor] (see MapColumnReader in
+ * test sources). Mirrors Calendula's ColumnReader.
+ */
+interface ColumnReader {
+ fun getLong(name: String): Long?
+ fun getInt(name: String): Int?
+ fun getString(name: String): String?
+ fun getBoolean(name: String): Boolean
+}
+
+/** Reads the [cursor]'s current row, caching column indices by name. */
+class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
+
+ private val indices = HashMap()
+
+ private fun idx(name: String): Int = indices.getOrPut(name) { cursor.getColumnIndex(name) }
+
+ override fun getLong(name: String): Long? {
+ val i = idx(name)
+ return if (i < 0 || cursor.isNull(i)) null else cursor.getLong(i)
+ }
+
+ override fun getInt(name: String): Int? {
+ val i = idx(name)
+ return if (i < 0 || cursor.isNull(i)) null else cursor.getInt(i)
+ }
+
+ override fun getString(name: String): String? {
+ val i = idx(name)
+ return if (i < 0 || cursor.isNull(i)) null else cursor.getString(i)
+ }
+
+ override fun getBoolean(name: String): Boolean = (getInt(name) ?: 0) != 0
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/Failures.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/Failures.kt
new file mode 100644
index 0000000..98942fe
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/Failures.kt
@@ -0,0 +1,9 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+/** No tasks provider (OpenTasks / tasks.org) is installed on the device. */
+class ProviderUnavailableException :
+ IllegalStateException("No tasks provider installed")
+
+/** A ContentResolver write returned no URI or affected no rows. */
+class TaskWriteFailedException(operation: String) :
+ RuntimeException("Task write failed: $operation")
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/ProviderResolver.kt
new file mode 100644
index 0000000..00fb24e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/ProviderResolver.kt
@@ -0,0 +1,69 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * A tasks provider Floret can talk to. The same dmfs `TaskProvider` backs every
+ * candidate, so the [TasksContract] columns apply regardless of which is present.
+ */
+data class TaskProvider(
+ val authority: String,
+ val readPermission: String,
+ val writePermission: String,
+ val packageName: String? = null,
+)
+
+/**
+ * The A/B seam. Detects which tasks provider is installed at runtime and which
+ * permission set it needs, so nothing above the data layer hardcodes an
+ * authority. Under Posture B (bundled provider) this simply finds our own
+ * `org.dmfs.tasks` first. See docs/PLAN.md.
+ */
+@Singleton
+class ProviderResolver @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ /** The active provider, or `null` when no tasks provider is installed. */
+ fun resolve(): TaskProvider? {
+ for (candidate in CANDIDATES) {
+ val info = context.packageManager.resolveContentProvider(candidate.authority, 0)
+ ?: continue
+ return candidate.copy(packageName = info.packageName)
+ }
+ return null
+ }
+
+ fun hasPermission(provider: TaskProvider): Boolean =
+ granted(provider.readPermission) && granted(provider.writePermission)
+
+ private fun granted(permission: String): Boolean =
+ ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
+
+ companion object {
+ /**
+ * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
+ * `org.dmfs.provider.tasks.TaskProvider`, guarded by
+ * `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks`
+ * + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical
+ * authority; on a device with only one installed, order is moot.
+ */
+ val CANDIDATES: List = listOf(
+ TaskProvider(
+ authority = "org.dmfs.tasks",
+ readPermission = "org.dmfs.permission.READ_TASKS",
+ writePermission = "org.dmfs.permission.WRITE_TASKS",
+ ),
+ TaskProvider(
+ authority = "org.tasks.opentasks",
+ readPermission = "org.tasks.permission.READ_TASKS",
+ writePermission = "org.tasks.permission.WRITE_TASKS",
+ ),
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskMapper.kt
new file mode 100644
index 0000000..40396a5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskMapper.kt
@@ -0,0 +1,58 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskList
+import de.jeanlucmakiola.floret.domain.priorityFromICal
+import de.jeanlucmakiola.floret.domain.statusFromInt
+import kotlin.time.Instant
+
+/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
+object TaskMapper {
+
+ fun task(r: ColumnReader): Task {
+ fun instant(name: String): Instant? =
+ r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
+
+ val instanceId = r.getLong(Tasks.ID) ?: 0L
+ return Task(
+ id = instanceId,
+ taskId = r.getLong(Instances.TASK_ID) ?: instanceId,
+ listId = r.getLong(Tasks.LIST_ID) ?: 0L,
+ title = r.getString(Tasks.TITLE).orEmpty(),
+ description = r.getString(Tasks.DESCRIPTION),
+ location = r.getString(Tasks.LOCATION),
+ url = r.getString(Tasks.URL),
+ priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
+ status = statusFromInt(r.getInt(Tasks.STATUS)),
+ percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
+ start = instant(Instances.INSTANCE_START),
+ due = instant(Instances.INSTANCE_DUE),
+ isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
+ timeZone = r.getString(Tasks.TZ),
+ completedAt = instant(Tasks.COMPLETED),
+ listColor = r.getInt(Tasks.LIST_COLOR) ?: 0,
+ taskColor = r.getInt(Tasks.TASK_COLOR),
+ listName = r.getString(Tasks.LIST_NAME),
+ accountName = r.getString(Tasks.ACCOUNT_NAME),
+ parentId = r.getLong(Tasks.PARENT_ID),
+ isRecurring = r.getBoolean(Instances.IS_RECURRING),
+ distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
+ created = instant(Tasks.CREATED),
+ lastModified = instant(Tasks.LAST_MODIFIED),
+ )
+ }
+
+ fun taskList(r: ColumnReader): TaskList = TaskList(
+ id = r.getLong(Lists.ID) ?: 0L,
+ name = r.getString(Lists.NAME).orEmpty(),
+ color = r.getInt(Lists.COLOR) ?: 0,
+ accountName = r.getString(Lists.ACCOUNT_NAME).orEmpty(),
+ accountType = r.getString(Lists.ACCOUNT_TYPE).orEmpty(),
+ isSynced = r.getBoolean(Lists.SYNC_ENABLED),
+ isVisible = r.getBoolean(Lists.VISIBLE),
+ owner = r.getString(Lists.OWNER),
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskProjections.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskProjections.kt
new file mode 100644
index 0000000..9113f56
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskProjections.kt
@@ -0,0 +1,48 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
+
+/** Column lists requested from the provider. Order is irrelevant; we read by name. */
+object TaskProjections {
+
+ val LISTS: Array = arrayOf(
+ Lists.ID,
+ Lists.NAME,
+ Lists.COLOR,
+ Lists.VISIBLE,
+ Lists.SYNC_ENABLED,
+ Lists.OWNER,
+ Lists.ACCOUNT_NAME,
+ Lists.ACCOUNT_TYPE,
+ )
+
+ /** Read from the `instances` view (inherits all task columns). */
+ val INSTANCES: Array = arrayOf(
+ Tasks.ID,
+ Instances.TASK_ID,
+ Tasks.LIST_ID,
+ Tasks.TITLE,
+ Tasks.DESCRIPTION,
+ Tasks.LOCATION,
+ Tasks.URL,
+ Tasks.PRIORITY,
+ Tasks.STATUS,
+ Tasks.PERCENT_COMPLETE,
+ Tasks.COMPLETED,
+ Tasks.IS_ALLDAY,
+ Tasks.TZ,
+ Instances.INSTANCE_START,
+ Instances.INSTANCE_DUE,
+ Tasks.TASK_COLOR,
+ Tasks.LIST_COLOR,
+ Tasks.LIST_NAME,
+ Tasks.ACCOUNT_NAME,
+ Tasks.PARENT_ID,
+ Instances.IS_RECURRING,
+ Instances.DISTANCE_FROM_CURRENT,
+ Tasks.CREATED,
+ Tasks.LAST_MODIFIED,
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapper.kt
new file mode 100644
index 0000000..9d92981
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapper.kt
@@ -0,0 +1,53 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
+import de.jeanlucmakiola.floret.domain.TaskForm
+import de.jeanlucmakiola.floret.domain.toICal
+
+/**
+ * Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
+ * so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
+ * ContentValues. `null` values are written as SQL NULL (clears the column).
+ */
+object TaskWriteMapper {
+
+ fun taskValues(form: TaskForm, tzId: String): Map = buildMap {
+ put(Tasks.TITLE, form.title.trim())
+ put(Tasks.LIST_ID, form.listId)
+ put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
+ put(Tasks.PRIORITY, form.priority.toICal())
+ put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
+ put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
+ put(Tasks.DUE, form.due?.toEpochMilliseconds())
+ put(Tasks.PARENT_ID, form.parentId)
+ // The provider treats a null tz as local time; set it explicitly for
+ // timed tasks so the stored instant is unambiguous across zones.
+ val timed = !form.isAllDay && (form.start != null || form.due != null)
+ put(Tasks.TZ, if (timed) tzId else null)
+ }
+
+ fun completionValues(completed: Boolean, nowMillis: Long): Map =
+ if (completed) {
+ mapOf(
+ Tasks.STATUS to TasksContract.STATUS_COMPLETED,
+ Tasks.PERCENT_COMPLETE to 100,
+ Tasks.COMPLETED to nowMillis,
+ )
+ } else {
+ mapOf(
+ Tasks.STATUS to TasksContract.STATUS_NEEDS_ACTION,
+ Tasks.PERCENT_COMPLETE to null,
+ Tasks.COMPLETED to null,
+ )
+ }
+
+ fun localListValues(name: String, color: Int): Map = mapOf(
+ Lists.NAME to name.trim(),
+ Lists.COLOR to color,
+ Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
+ Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
+ Lists.VISIBLE to 1,
+ Lists.SYNC_ENABLED to 1,
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksContract.kt
new file mode 100644
index 0000000..8bc38d4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksContract.kt
@@ -0,0 +1,122 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import android.net.Uri
+
+/**
+ * The subset of the OpenTasks `TaskContract` that Floret uses, vendored as
+ * Kotlin constants. Derived from `org.dmfs.tasks.contract.TaskContract`
+ * (Apache-2.0, dmfs GmbH):
+ * https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java
+ *
+ * The provider **authority** is supplied at runtime by [ProviderResolver] — it
+ * is `org.tasks.opentasks` when tasks.org is installed and `org.dmfs.tasks` for
+ * OpenTasks. Both run the identical dmfs `TaskProvider`, so every column name
+ * here applies verbatim to either (verified on-device against tasks.org's
+ * `org.dmfs.provider.tasks.TaskProvider`).
+ *
+ * Column names are plain strings, so the mappers that use them are unit-testable
+ * on the JVM without a real Cursor.
+ */
+object TasksContract {
+
+ // --- URI query parameters -------------------------------------------------
+ const val CALLER_IS_SYNCADAPTER = "caller_is_syncadapter"
+ const val PARAM_ACCOUNT_NAME = "account_name"
+ const val PARAM_ACCOUNT_TYPE = "account_type"
+ const val LOAD_PROPERTIES = "load_properties"
+
+ /** Account used for local, device-only task lists Floret owns. */
+ const val LOCAL_ACCOUNT_NAME = "Local"
+ const val LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"
+
+ /** The `tasklists` table — one row per task list. */
+ object Lists {
+ const val PATH = "tasklists"
+ const val ID = "_id"
+ const val NAME = "list_name"
+ const val COLOR = "list_color"
+ const val ACCESS_LEVEL = "list_access_level"
+ const val VISIBLE = "visible"
+ const val SYNC_ENABLED = "sync_enabled"
+ const val OWNER = "list_owner"
+ const val ACCOUNT_NAME = "account_name"
+ const val ACCOUNT_TYPE = "account_type"
+ }
+
+ /** The `tasks` table — the editable task rows (write target). */
+ object Tasks {
+ const val PATH = "tasks"
+ const val ID = "_id"
+ const val LIST_ID = "list_id"
+ const val TITLE = "title"
+ const val DESCRIPTION = "description"
+ const val LOCATION = "location"
+ const val URL = "url"
+ const val PRIORITY = "priority"
+ const val CLASSIFICATION = "class"
+ const val STATUS = "status"
+ const val PERCENT_COMPLETE = "percent_complete"
+ const val COMPLETED = "completed"
+ const val IS_CLOSED = "is_closed"
+ const val IS_NEW = "is_new"
+ const val TASK_COLOR = "task_color"
+ const val DTSTART = "dtstart"
+ const val DUE = "due"
+ const val DURATION = "duration"
+ const val IS_ALLDAY = "is_allday"
+ const val TZ = "tz"
+ const val RRULE = "rrule"
+ const val PARENT_ID = "parent_id"
+ const val SORTING = "sorting"
+ const val CREATED = "created"
+ const val LAST_MODIFIED = "last_modified"
+
+ // Auto-derived from the owning list (read-only on this table).
+ const val LIST_NAME = "list_name"
+ const val LIST_COLOR = "list_color"
+ const val ACCOUNT_NAME = "account_name"
+ const val ACCOUNT_TYPE = "account_type"
+
+ const val UID = "_uid"
+ const val DELETED = "_deleted"
+ }
+
+ /**
+ * The `instances` view — one row per task occurrence, with recurrence
+ * resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits
+ * every [Tasks] column (title, status, …) plus the instance-only columns
+ * below. This is Floret's read source.
+ */
+ object Instances {
+ const val PATH = "instances"
+ const val TASK_ID = "task_id"
+ const val INSTANCE_START = "instance_start"
+ const val INSTANCE_DUE = "instance_due"
+ const val INSTANCE_START_SORTING = "instance_start_sorting"
+ const val INSTANCE_DUE_SORTING = "instance_due_sorting"
+ const val DISTANCE_FROM_CURRENT = "distance_from_current"
+ const val IS_RECURRING = "is_recurring"
+ }
+
+ // --- status values (TaskColumns.STATUS_*) --------------------------------
+ const val STATUS_NEEDS_ACTION = 0
+ const val STATUS_IN_PROCESS = 1
+ const val STATUS_COMPLETED = 2
+ const val STATUS_CANCELLED = 3
+
+ /** Priority 0 means "no priority"; 1 is highest, 9 lowest (iCalendar). */
+ const val PRIORITY_NONE = 0
+
+ fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
+ fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
+ fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
+ fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
+
+ /** Append the sync-adapter params required to write local-account rows. */
+ fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
+ uri.buildUpon()
+ .appendQueryParameter(CALLER_IS_SYNCADAPTER, "true")
+ .appendQueryParameter(PARAM_ACCOUNT_NAME, accountName)
+ .appendQueryParameter(PARAM_ACCOUNT_TYPE, accountType)
+ .build()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksDataSource.kt
new file mode 100644
index 0000000..b6dc8a6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksDataSource.kt
@@ -0,0 +1,32 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskForm
+import de.jeanlucmakiola.floret.domain.TaskList
+
+/** What to fetch from the provider. Smart-list date logic is applied above this. */
+data class TaskQuery(
+ val listId: Long? = null,
+ val includeCompleted: Boolean = false,
+)
+
+/**
+ * Domain-shaped, synchronous seam over the tasks ContentResolver. Returns parsed
+ * lists so [TasksRepositoryImpl] can be tested with a fake on the JVM. All Cursor
+ * and ContentObserver handling lives in [AndroidTasksDataSource].
+ */
+interface TasksDataSource {
+ fun taskLists(): List
+ fun tasks(query: TaskQuery): List
+ fun task(taskId: Long): Task?
+ fun subtasks(parentTaskId: Long): List
+
+ fun insertTask(form: TaskForm): Long
+ fun updateTask(taskId: Long, form: TaskForm)
+ fun setCompleted(taskId: Long, completed: Boolean)
+ fun deleteTask(taskId: Long)
+ fun createLocalList(name: String, color: Int): Long
+
+ /** Observe any change to tasks/lists; [onChange] fires on a background thread. */
+ fun registerObserver(onChange: () -> Unit): AutoCloseable
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepository.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepository.kt
new file mode 100644
index 0000000..67c90fc
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepository.kt
@@ -0,0 +1,31 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskDetail
+import de.jeanlucmakiola.floret.domain.TaskFilter
+import de.jeanlucmakiola.floret.domain.TaskForm
+import de.jeanlucmakiola.floret.domain.TaskList
+import kotlinx.coroutines.flow.Flow
+
+/** Whether Floret can use the tasks provider right now. Drives onboarding. */
+enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
+
+/**
+ * The single entry point the UI layer uses. Flows re-emit automatically when the
+ * provider changes (our writes *and* external sync like DAVx5), via the
+ * data source's ContentObserver.
+ */
+interface TasksRepository {
+ fun taskLists(): Flow>
+ fun tasks(filter: TaskFilter): Flow>
+ fun taskDetail(taskId: Long): Flow
+
+ suspend fun createTask(form: TaskForm): Long
+ suspend fun updateTask(taskId: Long, form: TaskForm)
+ suspend fun setCompleted(taskId: Long, completed: Boolean)
+ suspend fun deleteTask(taskId: Long)
+ suspend fun createLocalList(name: String, color: Int): Long
+
+ /** Synchronous snapshot for the permission/onboarding gate. */
+ fun providerStatus(): ProviderStatus
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepositoryImpl.kt
new file mode 100644
index 0000000..22c34be
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepositoryImpl.kt
@@ -0,0 +1,94 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import de.jeanlucmakiola.floret.data.di.IoDispatcher
+import de.jeanlucmakiola.floret.domain.DayWindow
+import de.jeanlucmakiola.floret.domain.SmartList
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskDetail
+import de.jeanlucmakiola.floret.domain.TaskFilter
+import de.jeanlucmakiola.floret.domain.TaskFiltering
+import de.jeanlucmakiola.floret.domain.TaskForm
+import de.jeanlucmakiola.floret.domain.TaskList
+import de.jeanlucmakiola.floret.domain.TaskSorting
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.consumeEach
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.time.ZoneId
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Clock
+
+@Singleton
+class TasksRepositoryImpl @Inject constructor(
+ private val dataSource: TasksDataSource,
+ private val providerResolver: ProviderResolver,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) : TasksRepository {
+
+ override fun taskLists(): Flow> = observing { dataSource.taskLists() }
+
+ override fun tasks(filter: TaskFilter): Flow> = observing { loadTasks(filter) }
+
+ override fun taskDetail(taskId: Long): Flow = observing {
+ dataSource.task(taskId)?.let { task ->
+ TaskDetail(task, dataSource.subtasks(taskId).filter { it.taskId != taskId })
+ }
+ }
+
+ private fun loadTasks(filter: TaskFilter): List {
+ val query = when (filter) {
+ is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
+ is TaskFilter.Smart -> TaskQuery(includeCompleted = filter.list == SmartList.COMPLETED)
+ }
+ val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
+ return dataSource.tasks(query)
+ .filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
+ .sortedWith(TaskSorting.DEFAULT)
+ }
+
+ override suspend fun createTask(form: TaskForm): Long =
+ withContext(io) { dataSource.insertTask(form) }
+
+ override suspend fun updateTask(taskId: Long, form: TaskForm) =
+ withContext(io) { dataSource.updateTask(taskId, form) }
+
+ override suspend fun setCompleted(taskId: Long, completed: Boolean) =
+ withContext(io) { dataSource.setCompleted(taskId, completed) }
+
+ override suspend fun deleteTask(taskId: Long) =
+ withContext(io) { dataSource.deleteTask(taskId) }
+
+ override suspend fun createLocalList(name: String, color: Int): Long =
+ withContext(io) { dataSource.createLocalList(name, color) }
+
+ override fun providerStatus(): ProviderStatus {
+ val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
+ return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
+ else ProviderStatus.NEEDS_PERMISSION
+ }
+
+ /**
+ * Emits an initial load, then re-loads on every provider change. The observer
+ * callback (main thread) only pokes a conflated channel; the actual blocking
+ * query runs on [io].
+ */
+ private fun observing(load: () -> T): Flow = callbackFlow {
+ val ticks = Channel(Channel.CONFLATED)
+ val handle = dataSource.registerObserver { ticks.trySend(Unit) }
+ ticks.trySend(Unit) // prime the initial emission
+ val job = launch {
+ ticks.consumeEach { send(load()) }
+ }
+ awaitClose {
+ handle.close()
+ job.cancel()
+ ticks.close()
+ }
+ }.flowOn(io)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/domain/DayWindow.kt b/app/src/main/java/de/jeanlucmakiola/floret/domain/DayWindow.kt
new file mode 100644
index 0000000..374ea36
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/domain/DayWindow.kt
@@ -0,0 +1,18 @@
+package de.jeanlucmakiola.floret.domain
+
+import java.time.ZoneId
+import kotlin.time.Instant
+
+/** Local-day boundaries used by the smart-list predicates. Pure + testable. */
+object DayWindow {
+
+ /** Returns `[startOfToday, startOfTomorrow)` in [zone] for the instant [now]. */
+ fun today(now: Instant, zone: ZoneId): Pair {
+ val date = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds())
+ .atZone(zone)
+ .toLocalDate()
+ val start = date.atStartOfDay(zone).toInstant().toEpochMilli()
+ val end = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
+ return Instant.fromEpochMilliseconds(start) to Instant.fromEpochMilliseconds(end)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/floret/domain/Models.kt
new file mode 100644
index 0000000..aee2a93
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/domain/Models.kt
@@ -0,0 +1,98 @@
+package de.jeanlucmakiola.floret.domain
+
+import de.jeanlucmakiola.floret.data.tasks.TasksContract
+import kotlin.time.Instant
+
+/** A task list (the `tasklists` table). Lists group under their account. */
+data class TaskList(
+ val id: Long,
+ val name: String,
+ val color: Int,
+ val accountName: String,
+ val accountType: String,
+ val isSynced: Boolean,
+ val isVisible: Boolean,
+ val owner: String?,
+) {
+ /** A device-only list Floret (or another app) created locally, not synced. */
+ val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE
+}
+
+enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
+
+/** iCalendar priority, bucketed for the UI. */
+enum class Priority { NONE, LOW, MEDIUM, HIGH }
+
+/**
+ * A task occurrence as read from the `instances` view. [id] is the instance row
+ * id; [taskId] is the underlying `tasks._id` and the stable target for edits.
+ */
+data class Task(
+ val id: Long,
+ val taskId: Long,
+ val listId: Long,
+ val title: String,
+ val description: String?,
+ val location: String?,
+ val url: String?,
+ val priority: Priority,
+ val status: TaskStatus,
+ val percentComplete: Int?,
+ val start: Instant?,
+ val due: Instant?,
+ val isAllDay: Boolean,
+ val timeZone: String?,
+ val completedAt: Instant?,
+ val listColor: Int,
+ val taskColor: Int?,
+ val listName: String?,
+ val accountName: String?,
+ val parentId: Long?,
+ val isRecurring: Boolean,
+ val distanceFromCurrent: Int?,
+ val created: Instant?,
+ val lastModified: Instant?,
+) {
+ val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
+ val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
+ val isSubtask: Boolean get() = parentId != null && parentId > 0
+ /** The task's own colour if set, else the list colour. */
+ val effectiveColor: Int get() = taskColor ?: listColor
+}
+
+/** Detail bundle: a task plus its direct children. */
+data class TaskDetail(
+ val task: Task,
+ val subtasks: List,
+)
+
+// --- pure provider <-> domain value mapping (unit-testable) ------------------
+
+fun priorityFromICal(value: Int?): Priority = when {
+ value == null || value <= 0 -> Priority.NONE
+ value in 1..4 -> Priority.HIGH
+ value == 5 -> Priority.MEDIUM
+ else -> Priority.LOW // 6..9
+}
+
+/** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */
+fun Priority.toICal(): Int = when (this) {
+ Priority.NONE -> TasksContract.PRIORITY_NONE
+ Priority.HIGH -> 1
+ Priority.MEDIUM -> 5
+ Priority.LOW -> 9
+}
+
+fun statusFromInt(value: Int?): TaskStatus = when (value) {
+ TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS
+ TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED
+ TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED
+ else -> TaskStatus.NEEDS_ACTION
+}
+
+fun TaskStatus.toInt(): Int = when (this) {
+ TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION
+ TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS
+ TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED
+ TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskFilter.kt b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskFilter.kt
new file mode 100644
index 0000000..70d7858
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskFilter.kt
@@ -0,0 +1,38 @@
+package de.jeanlucmakiola.floret.domain
+
+import kotlin.time.Instant
+
+/** The built-in "smart" lists, computed from due dates rather than membership. */
+enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
+
+/** What a task screen is showing: one real list, or a smart list. */
+sealed interface TaskFilter {
+ data class OfList(val listId: Long) : TaskFilter
+ data class Smart(val list: SmartList) : TaskFilter
+}
+
+/**
+ * Pure smart-list predicate. [todayStart] is local midnight today and [todayEnd]
+ * local midnight tomorrow (compute with [DayWindow]). Kept side-effect-free so it
+ * unit-tests with a fixed clock.
+ */
+object TaskFiltering {
+
+ fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
+ when (filter) {
+ is TaskFilter.OfList -> task.listId == filter.listId
+ is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
+ }
+
+ private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
+ val due = task.due
+ return when (list) {
+ SmartList.COMPLETED -> task.isCompleted
+ SmartList.ALL -> !task.isClosed
+ SmartList.NO_DATE -> !task.isClosed && due == null
+ SmartList.OVERDUE -> !task.isClosed && due != null && due < todayStart
+ SmartList.TODAY -> !task.isClosed && due != null && due >= todayStart && due < todayEnd
+ SmartList.UPCOMING -> !task.isClosed && due != null && due >= todayEnd
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt
new file mode 100644
index 0000000..c570ad6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt
@@ -0,0 +1,32 @@
+package de.jeanlucmakiola.floret.domain
+
+import kotlin.time.Instant
+
+/**
+ * A validated create/edit form. Kept free of Android types so ViewModels and
+ * tests can build and validate it on the JVM. The data layer turns it into
+ * provider ContentValues ([de.jeanlucmakiola.floret.data.tasks.TaskWriteMapper]).
+ */
+data class TaskForm(
+ val title: String,
+ val listId: Long,
+ val description: String? = null,
+ val start: Instant? = null,
+ val due: Instant? = null,
+ val isAllDay: Boolean = false,
+ val priority: Priority = Priority.NONE,
+ val parentId: Long? = null,
+ /** Minutes before [due] to fire a reminder; `null` = no reminder. */
+ val reminderMinutesBeforeDue: Int? = null,
+) {
+ fun validate(): Set = buildSet {
+ if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
+ if (listId <= 0L) add(TaskFormError.NO_LIST)
+ if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
+ if (reminderMinutesBeforeDue != null && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
+ }
+
+ val isValid: Boolean get() = validate().isEmpty()
+}
+
+enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE }
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskSorting.kt b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskSorting.kt
new file mode 100644
index 0000000..98915ba
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskSorting.kt
@@ -0,0 +1,13 @@
+package de.jeanlucmakiola.floret.domain
+
+/** Default task ordering, extracted so it's unit-testable without the repository. */
+object TaskSorting {
+ /** Open before completed; within each: due-soonest (none last), priority, title. */
+ val DEFAULT: Comparator = compareBy(
+ { it.isCompleted },
+ { it.due == null },
+ { it.due?.toEpochMilliseconds() ?: Long.MAX_VALUE },
+ { -it.priority.ordinal },
+ { it.title.lowercase() },
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt
index 3470fd0..2870cae 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt
@@ -1,48 +1,192 @@
package de.jeanlucmakiola.floret.ui
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R
+import de.jeanlucmakiola.floret.data.tasks.ProviderStatus
+import de.jeanlucmakiola.floret.domain.SmartList
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskFilter
+import de.jeanlucmakiola.floret.ui.lists.ListsUiState
+import de.jeanlucmakiola.floret.ui.lists.ListsViewModel
+import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel
+import de.jeanlucmakiola.floret.ui.tasklist.TaskListUiState
+import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
/**
- * M0 placeholder root. Proves the theme, Hilt entry point and edge-to-edge
- * scaffold render. Replaced in M1 by the lists overview + nav host
- * (FloretHost), the analog of Calendula's CalendarHost.
+ * ⚠️ Functional scaffold, not final UI. It proves the data layer end to end
+ * (provider gating → live lists/tasks → toggle/add) and is the canvas to replace
+ * screen-by-screen with the real Material 3 Expressive design (see docs/PLAN.md
+ * §4): ListsScreen, TaskListScreen, TaskDetail/Edit, Settings. The ViewModels it
+ * uses are the finished, render-only state holders those screens will consume.
*/
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun RootScreen(modifier: Modifier = Modifier) {
- Scaffold(modifier = modifier) { inner ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(inner)
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- ) {
- Text(
- text = stringResource(R.string.app_name),
- style = MaterialTheme.typography.displaySmall,
- color = MaterialTheme.colorScheme.primary,
- )
- Text(
- text = stringResource(R.string.app_tagline),
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- modifier = Modifier.padding(top = 8.dp),
- )
+fun RootScreen(
+ modifier: Modifier = Modifier,
+ permissionViewModel: PermissionViewModel = hiltViewModel(),
+) {
+ val permission by permissionViewModel.state.collectAsStateWithLifecycle()
+ val launcher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions(),
+ ) { permissionViewModel.refresh() }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = { TopAppBar(title = { Text(stringResource(R.string.app_name)) }) },
+ ) { inner ->
+ Column(Modifier.padding(inner).fillMaxSize()) {
+ when (permission.status) {
+ ProviderStatus.NO_PROVIDER -> Gate(
+ title = stringResource(R.string.onboarding_no_provider_title),
+ body = stringResource(R.string.onboarding_no_provider_body),
+ )
+ ProviderStatus.NEEDS_PERMISSION -> Gate(
+ title = stringResource(R.string.onboarding_permission_title),
+ body = stringResource(R.string.onboarding_permission_body),
+ action = stringResource(R.string.onboarding_permission_button),
+ onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
+ )
+ ProviderStatus.READY -> TasksContent()
+ }
}
}
}
+
+@Composable
+private fun Gate(title: String, body: String, action: String? = null, onAction: () -> Unit = {}) {
+ Column(
+ Modifier.fillMaxSize().padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
+ ) {
+ Text(title, style = MaterialTheme.typography.headlineSmall)
+ Text(body, style = MaterialTheme.typography.bodyMedium)
+ if (action != null) Button(onClick = onAction) { Text(action) }
+ }
+}
+
+@Composable
+private fun TasksContent(
+ listsViewModel: ListsViewModel = hiltViewModel(),
+ taskListViewModel: TaskListViewModel = hiltViewModel(),
+) {
+ androidx.compose.runtime.LaunchedEffect(Unit) {
+ taskListViewModel.bind(TaskFilter.Smart(SmartList.ALL))
+ }
+ val lists by listsViewModel.state.collectAsStateWithLifecycle()
+ val tasks by taskListViewModel.state.collectAsStateWithLifecycle()
+
+ val firstListId = (lists as? ListsUiState.Content)
+ ?.groups?.firstOrNull()?.lists?.firstOrNull()?.list?.id
+
+ var newTitle by remember { mutableStateOf("") }
+
+ LazyColumn(Modifier.fillMaxSize()) {
+ item { SectionHeader("Lists") }
+ when (val l = lists) {
+ ListsUiState.Loading -> item { ListItem(headlineContent = { Text("Loading…") }) }
+ ListsUiState.Failure -> item { ListItem(headlineContent = { Text("Could not read tasks.") }) }
+ is ListsUiState.Content -> {
+ items(l.smartCounts) { sc ->
+ ListItem(
+ headlineContent = { Text(sc.smart.name) },
+ trailingContent = { Text(sc.count.toString()) },
+ )
+ }
+ l.groups.forEach { group ->
+ item { SectionHeader(group.accountName) }
+ items(group.lists) { overview ->
+ ListItem(
+ headlineContent = { Text(overview.list.name) },
+ trailingContent = { Text(overview.openCount.toString()) },
+ )
+ }
+ }
+ }
+ }
+
+ item { HorizontalDivider() }
+ item { SectionHeader("All open tasks") }
+ when (val t = tasks) {
+ TaskListUiState.Loading -> item { ListItem(headlineContent = { Text("Loading…") }) }
+ TaskListUiState.Failure -> item { ListItem(headlineContent = { Text("Could not read tasks.") }) }
+ is TaskListUiState.Content -> items(t.tasks) { task ->
+ TaskRow(task = task, onToggle = { taskListViewModel.toggleComplete(task) })
+ }
+ }
+
+ if (firstListId != null) {
+ item {
+ OutlinedTextField(
+ value = newTitle,
+ onValueChange = { newTitle = it },
+ label = { Text("New task") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ )
+ }
+ item {
+ Button(
+ onClick = {
+ taskListViewModel.quickAdd(newTitle, firstListId)
+ newTitle = ""
+ },
+ modifier = Modifier.padding(horizontal = 16.dp),
+ ) { Text("Add") }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TaskRow(task: Task, onToggle: () -> Unit) {
+ ListItem(
+ leadingContent = { Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) },
+ headlineContent = {
+ Text(
+ task.title.ifBlank { stringResource(R.string.task_untitled) },
+ textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
+ )
+ },
+ supportingContent = task.listName?.let { { Text(it) } },
+ )
+}
+
+@Composable
+private fun SectionHeader(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailViewModel.kt
new file mode 100644
index 0000000..af4c1e7
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailViewModel.kt
@@ -0,0 +1,57 @@
+package de.jeanlucmakiola.floret.ui.detail
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.floret.data.tasks.TasksRepository
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskDetail
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+sealed interface TaskDetailUiState {
+ data object Loading : TaskDetailUiState
+ data object NotFound : TaskDetailUiState
+ data class Content(val detail: TaskDetail) : TaskDetailUiState
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@HiltViewModel
+class TaskDetailViewModel @Inject constructor(
+ private val repository: TasksRepository,
+) : ViewModel() {
+
+ private val taskId = MutableStateFlow(null)
+
+ val state: StateFlow =
+ taskId.filterNotNull()
+ .flatMapLatest { id ->
+ repository.taskDetail(id)
+ .map { detail ->
+ if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
+ }
+ .onStart { emit(TaskDetailUiState.Loading) }
+ .catch { emit(TaskDetailUiState.NotFound) }
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
+
+ fun bind(id: Long) { taskId.value = id }
+
+ fun toggleComplete(task: Task) = viewModelScope.launch {
+ runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
+ }
+
+ fun delete(task: Task) = viewModelScope.launch {
+ runCatching { repository.deleteTask(task.taskId) }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt
new file mode 100644
index 0000000..14cccbc
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt
@@ -0,0 +1,144 @@
+package de.jeanlucmakiola.floret.ui.edit
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
+import de.jeanlucmakiola.floret.data.tasks.TasksRepository
+import de.jeanlucmakiola.floret.domain.Priority
+import de.jeanlucmakiola.floret.domain.TaskForm
+import de.jeanlucmakiola.floret.domain.TaskFormError
+import de.jeanlucmakiola.floret.domain.TaskList
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.time.Instant
+
+/**
+ * Editable form state for creating or editing a task. The screen renders these
+ * fields and calls the `on*` setters; [save] validates, writes, re-syncs
+ * reminders, and flips [TaskEditUiState.saved] for the screen to navigate back.
+ */
+data class TaskEditUiState(
+ val loading: Boolean = true,
+ val isNew: Boolean = true,
+ val title: String = "",
+ val description: String = "",
+ val listId: Long? = null,
+ val start: Instant? = null,
+ val due: Instant? = null,
+ val isAllDay: Boolean = false,
+ val priority: Priority = Priority.NONE,
+ val parentId: Long? = null,
+ val reminderMinutesBeforeDue: Int? = null,
+ val lists: List = emptyList(),
+ val errors: Set = emptySet(),
+ val saveFailed: Boolean = false,
+ val saved: Boolean = false,
+)
+
+@HiltViewModel
+class TaskEditViewModel @Inject constructor(
+ private val repository: TasksRepository,
+ private val settingsPrefs: SettingsPrefs,
+ private val reminderScheduler: ReminderScheduler,
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(TaskEditUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private var editingTaskId: Long? = null
+
+ /** Start a fresh task, optionally pre-selecting a list / parent. */
+ fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
+ editingTaskId = null
+ viewModelScope.launch {
+ val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
+ val defaultList = presetListId
+ ?: settingsPrefs.settings.first().defaultListId
+ ?: lists.firstOrNull { !it.isLocal }?.id
+ ?: lists.firstOrNull()?.id
+ _state.value = TaskEditUiState(
+ loading = false,
+ isNew = true,
+ listId = defaultList,
+ parentId = parentId,
+ lists = lists,
+ )
+ }
+ }
+
+ /** Load an existing task for editing. */
+ fun bindEdit(taskId: Long) {
+ editingTaskId = taskId
+ viewModelScope.launch {
+ val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
+ val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
+ if (task == null) {
+ _state.value = _state.value.copy(loading = false, lists = lists)
+ return@launch
+ }
+ _state.value = TaskEditUiState(
+ loading = false,
+ isNew = false,
+ title = task.title,
+ description = task.description.orEmpty(),
+ listId = task.listId,
+ start = task.start,
+ due = task.due,
+ isAllDay = task.isAllDay,
+ priority = task.priority,
+ parentId = task.parentId,
+ lists = lists,
+ )
+ }
+ }
+
+ fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
+ fun onDescriptionChange(value: String) = update { it.copy(description = value) }
+ fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) }
+ fun onStartChange(value: Instant?) = update { it.copy(start = value) }
+ fun onDueChange(value: Instant?) = update { it.copy(due = value) }
+ fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
+ fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
+ fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
+
+ fun save() {
+ val current = _state.value
+ val form = TaskForm(
+ title = current.title,
+ listId = current.listId ?: 0L,
+ description = current.description.ifBlank { null },
+ start = current.start,
+ due = current.due,
+ isAllDay = current.isAllDay,
+ priority = current.priority,
+ parentId = current.parentId,
+ reminderMinutesBeforeDue = current.reminderMinutesBeforeDue,
+ )
+ val errors = form.validate()
+ if (errors.isNotEmpty()) {
+ _state.value = current.copy(errors = errors)
+ return
+ }
+ viewModelScope.launch {
+ runCatching {
+ val id = editingTaskId
+ if (id == null) repository.createTask(form) else repository.updateTask(id, form)
+ reminderScheduler.sync()
+ }.onSuccess {
+ _state.value = _state.value.copy(saved = true, saveFailed = false)
+ }.onFailure {
+ _state.value = _state.value.copy(saveFailed = true)
+ }
+ }
+ }
+
+ private inline fun update(block: (TaskEditUiState) -> TaskEditUiState) {
+ _state.value = block(_state.value)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsViewModel.kt
new file mode 100644
index 0000000..a7a69ad
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsViewModel.kt
@@ -0,0 +1,73 @@
+package de.jeanlucmakiola.floret.ui.lists
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.floret.data.tasks.TasksRepository
+import de.jeanlucmakiola.floret.domain.DayWindow
+import de.jeanlucmakiola.floret.domain.SmartList
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskFilter
+import de.jeanlucmakiola.floret.domain.TaskFiltering
+import de.jeanlucmakiola.floret.domain.TaskList
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import java.time.ZoneId
+import javax.inject.Inject
+import kotlin.time.Clock
+
+data class ListOverview(val list: TaskList, val openCount: Int)
+data class AccountGroup(val accountName: String, val lists: List)
+data class SmartCount(val smart: SmartList, val count: Int)
+
+sealed interface ListsUiState {
+ data object Loading : ListsUiState
+ data object Failure : ListsUiState
+ data class Content(
+ val smartCounts: List,
+ val groups: List,
+ ) : ListsUiState
+}
+
+/** The home overview: smart lists with live counts, then user lists by account. */
+@HiltViewModel
+class ListsViewModel @Inject constructor(
+ repository: TasksRepository,
+) : ViewModel() {
+
+ val state: StateFlow =
+ combine(
+ repository.taskLists(),
+ repository.tasks(TaskFilter.Smart(SmartList.ALL)),
+ ) { lists, openTasks ->
+ buildContent(lists, openTasks) as ListsUiState
+ }.catch { emit(ListsUiState.Failure) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
+
+ private fun buildContent(lists: List, openTasks: List): ListsUiState.Content {
+ val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
+ fun count(smart: SmartList) =
+ openTasks.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
+
+ val smartCounts = listOf(
+ SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
+ SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
+ SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
+ SmartCount(SmartList.ALL, openTasks.size),
+ )
+ val openByList = openTasks.groupingBy { it.listId }.eachCount()
+ val groups = lists
+ .groupBy { it.accountName }
+ .map { (account, accountLists) ->
+ AccountGroup(
+ accountName = account,
+ lists = accountLists.map { ListOverview(it, openByList[it.id] ?: 0) },
+ )
+ }
+ .sortedBy { it.accountName.lowercase() }
+ return ListsUiState.Content(smartCounts, groups)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/PermissionViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/PermissionViewModel.kt
new file mode 100644
index 0000000..df82d63
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/PermissionViewModel.kt
@@ -0,0 +1,45 @@
+package de.jeanlucmakiola.floret.ui.permission
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.floret.data.tasks.ProviderResolver
+import de.jeanlucmakiola.floret.data.tasks.ProviderStatus
+import de.jeanlucmakiola.floret.data.tasks.TasksRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+
+data class PermissionUiState(
+ val status: ProviderStatus = ProviderStatus.NO_PROVIDER,
+ /** The active provider's runtime permissions to request, when one is present. */
+ val permissionsToRequest: List = emptyList(),
+)
+
+/**
+ * Gates app entry: is a tasks provider installed, and do we hold its permissions?
+ * The Composable owns the actual permission-launcher and store intents; this VM
+ * supplies the [status] and the exact permission strings to ask for.
+ */
+@HiltViewModel
+class PermissionViewModel @Inject constructor(
+ private val repository: TasksRepository,
+ private val providerResolver: ProviderResolver,
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(PermissionUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ init { refresh() }
+
+ /** Re-read provider + permission state (call after returning from a request). */
+ fun refresh() {
+ val provider = providerResolver.resolve()
+ _state.value = PermissionUiState(
+ status = repository.providerStatus(),
+ permissionsToRequest = provider
+ ?.let { listOf(it.readPermission, it.writePermission) }
+ .orEmpty(),
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..835a025
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,43 @@
+package de.jeanlucmakiola.floret.ui.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.floret.data.prefs.Settings
+import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.floret.data.prefs.ThemeMode
+import de.jeanlucmakiola.floret.data.tasks.TasksRepository
+import de.jeanlucmakiola.floret.domain.TaskList
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class SettingsUiState(
+ val settings: Settings = Settings(),
+ val lists: List = emptyList(),
+)
+
+/**
+ * Drives both the Settings screen and the app theme (MainActivity collects the
+ * same instance), so a theme change applies app-wide at once.
+ */
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val prefs: SettingsPrefs,
+ repository: TasksRepository,
+) : ViewModel() {
+
+ val state: StateFlow =
+ combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists ->
+ SettingsUiState(settings, lists)
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
+
+ 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) }
+ fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt
new file mode 100644
index 0000000..b6c200d
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt
@@ -0,0 +1,67 @@
+package de.jeanlucmakiola.floret.ui.tasklist
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.floret.data.tasks.TasksRepository
+import de.jeanlucmakiola.floret.domain.Task
+import de.jeanlucmakiola.floret.domain.TaskFilter
+import de.jeanlucmakiola.floret.domain.TaskForm
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+sealed interface TaskListUiState {
+ data object Loading : TaskListUiState
+ data object Failure : TaskListUiState
+ data class Content(val tasks: List) : TaskListUiState
+}
+
+/**
+ * The tasks for one [TaskFilter]. Call [bind] with the filter the screen shows;
+ * the list re-emits live as the provider changes. Actions are fire-and-forget —
+ * the resulting provider change flows back through the list automatically.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@HiltViewModel
+class TaskListViewModel @Inject constructor(
+ private val repository: TasksRepository,
+) : ViewModel() {
+
+ private val filter = MutableStateFlow(null)
+
+ val state: StateFlow =
+ filter.filterNotNull()
+ .flatMapLatest { f ->
+ repository.tasks(f)
+ .map, TaskListUiState> { TaskListUiState.Content(it) }
+ .onStart { emit(TaskListUiState.Loading) }
+ .catch { emit(TaskListUiState.Failure) }
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
+
+ fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
+
+ fun toggleComplete(task: Task) = viewModelScope.launch {
+ runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
+ }
+
+ fun delete(task: Task) = viewModelScope.launch {
+ runCatching { repository.deleteTask(task.taskId) }
+ }
+
+ /** Inline "add task" — title only, into [listId]. */
+ fun quickAdd(title: String, listId: Long) = viewModelScope.launch {
+ if (title.isBlank() || listId <= 0L) return@launch
+ runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 0000000..0170e60
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6173882..55188ec 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,21 @@
Floret
A modern Material 3 Expressive task app.\nComing into bloom.
+
+
+ Untitled task
+
+
+ Due %1$s
+ Task reminders
+ Notifications for tasks that are due.
+
+
+ A tasks app is needed
+ Floret shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.
+ Allow access to your tasks
+ Floret needs permission to read and write your tasks. That\'s the only thing it ever asks for.
+ Grant task access
+ Install OpenTasks
+ Install tasks.org
diff --git a/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/MapColumnReader.kt b/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/MapColumnReader.kt
new file mode 100644
index 0000000..6ad038d
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/MapColumnReader.kt
@@ -0,0 +1,9 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+/** A [ColumnReader] backed by a Map, so mappers test without a real Cursor. */
+class MapColumnReader(private val values: Map) : ColumnReader {
+ override fun getLong(name: String): Long? = (values[name] as? Number)?.toLong()
+ override fun getInt(name: String): Int? = (values[name] as? Number)?.toInt()
+ override fun getString(name: String): String? = values[name] as? String
+ override fun getBoolean(name: String): Boolean = ((values[name] as? Number)?.toInt() ?: 0) != 0
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskMapperTest.kt
new file mode 100644
index 0000000..0d2a763
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskMapperTest.kt
@@ -0,0 +1,84 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
+import de.jeanlucmakiola.floret.domain.Priority
+import de.jeanlucmakiola.floret.domain.TaskStatus
+import org.junit.jupiter.api.Test
+
+class TaskMapperTest {
+
+ @Test
+ fun `maps an instance row to a Task`() {
+ val reader = MapColumnReader(
+ mapOf(
+ Tasks.ID to 42L,
+ Instances.TASK_ID to 7L,
+ Tasks.LIST_ID to 3L,
+ Tasks.TITLE to "Buy milk",
+ Tasks.DESCRIPTION to "2%",
+ Tasks.PRIORITY to 1,
+ Tasks.STATUS to TasksContract.STATUS_IN_PROCESS,
+ Tasks.PERCENT_COMPLETE to 40,
+ Instances.INSTANCE_DUE to 1_000L,
+ Instances.INSTANCE_START to 500L,
+ Tasks.IS_ALLDAY to 0,
+ Tasks.LIST_COLOR to 0x123456,
+ Tasks.TASK_COLOR to 0xABCDEF,
+ Tasks.LIST_NAME to "Groceries",
+ Tasks.PARENT_ID to 7L,
+ Instances.IS_RECURRING to 1,
+ Instances.DISTANCE_FROM_CURRENT to 0,
+ ),
+ )
+
+ val task = TaskMapper.task(reader)
+
+ assertThat(task.id).isEqualTo(42L)
+ assertThat(task.taskId).isEqualTo(7L)
+ assertThat(task.listId).isEqualTo(3L)
+ assertThat(task.title).isEqualTo("Buy milk")
+ assertThat(task.priority).isEqualTo(Priority.HIGH)
+ assertThat(task.status).isEqualTo(TaskStatus.IN_PROCESS)
+ assertThat(task.percentComplete).isEqualTo(40)
+ assertThat(task.due?.toEpochMilliseconds()).isEqualTo(1_000L)
+ assertThat(task.start?.toEpochMilliseconds()).isEqualTo(500L)
+ assertThat(task.isRecurring).isTrue()
+ assertThat(task.effectiveColor).isEqualTo(0xABCDEF)
+ assertThat(task.isSubtask).isTrue()
+ }
+
+ @Test
+ fun `falls back to instance id when task_id missing, and list color when no task color`() {
+ val task = TaskMapper.task(
+ MapColumnReader(mapOf(Tasks.ID to 9L, Tasks.LIST_COLOR to 0x111111)),
+ )
+ assertThat(task.taskId).isEqualTo(9L)
+ assertThat(task.effectiveColor).isEqualTo(0x111111)
+ assertThat(task.title).isEmpty()
+ assertThat(task.due).isNull()
+ }
+
+ @Test
+ fun `maps a task list row`() {
+ val list = TaskMapper.taskList(
+ MapColumnReader(
+ mapOf(
+ Lists.ID to 5L,
+ Lists.NAME to "Work",
+ Lists.COLOR to 0x00FF00,
+ Lists.ACCOUNT_NAME to "me@dav",
+ Lists.ACCOUNT_TYPE to "bitfire.at.davdroid",
+ Lists.SYNC_ENABLED to 1,
+ Lists.VISIBLE to 1,
+ ),
+ ),
+ )
+ assertThat(list.id).isEqualTo(5L)
+ assertThat(list.name).isEqualTo("Work")
+ assertThat(list.isSynced).isTrue()
+ assertThat(list.isLocal).isFalse()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapperTest.kt
new file mode 100644
index 0000000..81a001d
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapperTest.kt
@@ -0,0 +1,63 @@
+package de.jeanlucmakiola.floret.data.tasks
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
+import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
+import de.jeanlucmakiola.floret.domain.Priority
+import de.jeanlucmakiola.floret.domain.TaskForm
+import org.junit.jupiter.api.Test
+import kotlin.time.Instant
+
+class TaskWriteMapperTest {
+
+ @Test
+ fun `timed task writes due, priority and an explicit timezone`() {
+ val form = TaskForm(
+ title = " Pay rent ",
+ listId = 4L,
+ due = Instant.fromEpochMilliseconds(2_000L),
+ priority = Priority.HIGH,
+ )
+ val values = TaskWriteMapper.taskValues(form, tzId = "Europe/Berlin")
+
+ assertThat(values[Tasks.TITLE]).isEqualTo("Pay rent")
+ assertThat(values[Tasks.LIST_ID]).isEqualTo(4L)
+ assertThat(values[Tasks.DUE]).isEqualTo(2_000L)
+ assertThat(values[Tasks.PRIORITY]).isEqualTo(1)
+ assertThat(values[Tasks.IS_ALLDAY]).isEqualTo(0)
+ assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin")
+ }
+
+ @Test
+ fun `all-day task clears the timezone`() {
+ val form = TaskForm(
+ title = "Holiday",
+ listId = 1L,
+ due = Instant.fromEpochMilliseconds(0L),
+ isAllDay = true,
+ )
+ val values = TaskWriteMapper.taskValues(form, tzId = "Europe/Berlin")
+ assertThat(values[Tasks.IS_ALLDAY]).isEqualTo(1)
+ assertThat(values[Tasks.TZ]).isNull()
+ }
+
+ @Test
+ fun `completion sets status, percent and timestamp, un-completion clears them`() {
+ val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
+ assertThat(done[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED)
+ assertThat(done[Tasks.PERCENT_COMPLETE]).isEqualTo(100)
+ assertThat(done[Tasks.COMPLETED]).isEqualTo(999L)
+
+ val undone = TaskWriteMapper.completionValues(completed = false, nowMillis = 999L)
+ assertThat(undone[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_NEEDS_ACTION)
+ assertThat(undone[Tasks.COMPLETED]).isNull()
+ }
+
+ @Test
+ fun `local list uses the LOCAL account`() {
+ val values = TaskWriteMapper.localListValues("Inbox", 0x123)
+ assertThat(values[Lists.NAME]).isEqualTo("Inbox")
+ assertThat(values[Lists.ACCOUNT_TYPE]).isEqualTo(TasksContract.LOCAL_ACCOUNT_TYPE)
+ assertThat(values[Lists.ACCOUNT_NAME]).isEqualTo(TasksContract.LOCAL_ACCOUNT_NAME)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/floret/domain/DayWindowTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/domain/DayWindowTest.kt
new file mode 100644
index 0000000..7af2bd7
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/floret/domain/DayWindowTest.kt
@@ -0,0 +1,35 @@
+package de.jeanlucmakiola.floret.domain
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+import java.time.ZoneId
+import kotlin.time.Instant
+
+class DayWindowTest {
+
+ @Test
+ fun `today spans exactly 24h in UTC and contains now`() {
+ val zone = ZoneId.of("UTC")
+ val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
+ val (start, end) = DayWindow.today(now, zone)
+
+ assertThat(end.toEpochMilliseconds() - start.toEpochMilliseconds()).isEqualTo(86_400_000L)
+ assertThat(now >= start).isTrue()
+ assertThat(now < end).isTrue()
+ // In UTC, midnight aligns to a multiple of one day since the epoch.
+ assertThat(start.toEpochMilliseconds() % 86_400_000L).isEqualTo(0L)
+ }
+
+ @Test
+ fun `start is local midnight in a non-UTC zone`() {
+ val zone = ZoneId.of("Europe/Berlin")
+ val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
+ val (start, end) = DayWindow.today(now, zone)
+
+ val startLocal = java.time.Instant.ofEpochMilli(start.toEpochMilliseconds()).atZone(zone)
+ assertThat(startLocal.hour).isEqualTo(0)
+ assertThat(startLocal.minute).isEqualTo(0)
+ assertThat(now >= start).isTrue()
+ assertThat(now < end).isTrue()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/floret/domain/ModelsTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/domain/ModelsTest.kt
new file mode 100644
index 0000000..8000ae4
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/floret/domain/ModelsTest.kt
@@ -0,0 +1,34 @@
+package de.jeanlucmakiola.floret.domain
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class ModelsTest {
+
+ @Test
+ fun `priority maps from iCalendar buckets`() {
+ assertThat(priorityFromICal(null)).isEqualTo(Priority.NONE)
+ assertThat(priorityFromICal(0)).isEqualTo(Priority.NONE)
+ assertThat(priorityFromICal(1)).isEqualTo(Priority.HIGH)
+ assertThat(priorityFromICal(4)).isEqualTo(Priority.HIGH)
+ assertThat(priorityFromICal(5)).isEqualTo(Priority.MEDIUM)
+ assertThat(priorityFromICal(6)).isEqualTo(Priority.LOW)
+ assertThat(priorityFromICal(9)).isEqualTo(Priority.LOW)
+ }
+
+ @Test
+ fun `priority maps back to representative iCalendar values`() {
+ assertThat(Priority.NONE.toICal()).isEqualTo(0)
+ assertThat(Priority.HIGH.toICal()).isEqualTo(1)
+ assertThat(Priority.MEDIUM.toICal()).isEqualTo(5)
+ assertThat(Priority.LOW.toICal()).isEqualTo(9)
+ }
+
+ @Test
+ fun `status round-trips through its integer code`() {
+ TaskStatus.entries.forEach { status ->
+ assertThat(statusFromInt(status.toInt())).isEqualTo(status)
+ }
+ assertThat(statusFromInt(null)).isEqualTo(TaskStatus.NEEDS_ACTION)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFilteringTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFilteringTest.kt
new file mode 100644
index 0000000..9e0fcfd
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFilteringTest.kt
@@ -0,0 +1,61 @@
+package de.jeanlucmakiola.floret.domain
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+import kotlin.time.Instant
+
+class TaskFilteringTest {
+
+ private val dayMs = 86_400_000L
+ private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs)
+ private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs)
+
+ private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis)
+ private fun matches(task: Task, smart: SmartList) =
+ TaskFiltering.matches(task, TaskFilter.Smart(smart), todayStart, todayEnd)
+
+ @Test
+ fun `overdue is open with a due before today`() {
+ val task = testTask(due = at(1000 * dayMs - 1))
+ assertThat(matches(task, SmartList.OVERDUE)).isTrue()
+ assertThat(matches(task, SmartList.TODAY)).isFalse()
+ assertThat(matches(task, SmartList.UPCOMING)).isFalse()
+ }
+
+ @Test
+ fun `today is open with due within today`() {
+ val task = testTask(due = at(1000 * dayMs + 1))
+ assertThat(matches(task, SmartList.TODAY)).isTrue()
+ assertThat(matches(task, SmartList.OVERDUE)).isFalse()
+ }
+
+ @Test
+ fun `upcoming is open with due tomorrow or later`() {
+ val task = testTask(due = at(1001 * dayMs + 1))
+ assertThat(matches(task, SmartList.UPCOMING)).isTrue()
+ assertThat(matches(task, SmartList.TODAY)).isFalse()
+ }
+
+ @Test
+ fun `no-date is open without a due`() {
+ val task = testTask(due = null)
+ assertThat(matches(task, SmartList.NO_DATE)).isTrue()
+ assertThat(matches(task, SmartList.ALL)).isTrue()
+ assertThat(matches(task, SmartList.UPCOMING)).isFalse()
+ }
+
+ @Test
+ fun `completed only matches COMPLETED, never the open lists`() {
+ val task = testTask(status = TaskStatus.COMPLETED, due = at(1000 * dayMs + 1))
+ assertThat(matches(task, SmartList.COMPLETED)).isTrue()
+ assertThat(matches(task, SmartList.ALL)).isFalse()
+ assertThat(matches(task, SmartList.TODAY)).isFalse()
+ }
+
+ @Test
+ fun `OfList matches by list id`() {
+ val task = testTask(listId = 7)
+ assertThat(TaskFiltering.matches(task, TaskFilter.OfList(7), todayStart, todayEnd)).isTrue()
+ assertThat(TaskFiltering.matches(task, TaskFilter.OfList(8), todayStart, todayEnd)).isFalse()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFormTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFormTest.kt
new file mode 100644
index 0000000..c749d01
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFormTest.kt
@@ -0,0 +1,38 @@
+package de.jeanlucmakiola.floret.domain
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+import kotlin.time.Instant
+
+class TaskFormTest {
+
+ private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis)
+
+ @Test
+ fun `a titled task in a list is valid`() {
+ assertThat(TaskForm(title = "Write tests", listId = 1).validate()).isEmpty()
+ }
+
+ @Test
+ fun `blank title and missing list are errors`() {
+ val errors = TaskForm(title = " ", listId = 0).validate()
+ assertThat(errors).containsExactly(TaskFormError.BLANK_TITLE, TaskFormError.NO_LIST)
+ }
+
+ @Test
+ fun `due before start is an error`() {
+ val errors = TaskForm(
+ title = "x",
+ listId = 1,
+ start = at(1000),
+ due = at(500),
+ ).validate()
+ assertThat(errors).contains(TaskFormError.DUE_BEFORE_START)
+ }
+
+ @Test
+ fun `a reminder requires a due date`() {
+ val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate()
+ assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskSortingTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskSortingTest.kt
new file mode 100644
index 0000000..b2fc0e5
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskSortingTest.kt
@@ -0,0 +1,32 @@
+package de.jeanlucmakiola.floret.domain
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+import kotlin.time.Instant
+
+class TaskSortingTest {
+
+ private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis)
+
+ @Test
+ fun `open before completed, due-soonest first, dateless last`() {
+ val completed = testTask(id = 1, title = "done", status = TaskStatus.COMPLETED, due = at(10))
+ val dueLater = testTask(id = 2, title = "later", due = at(200))
+ val dueSooner = testTask(id = 3, title = "sooner", due = at(100))
+ val noDate = testTask(id = 4, title = "someday", due = null)
+
+ val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT)
+
+ assertThat(sorted.map { it.id }).containsExactly(3L, 2L, 4L, 1L).inOrder()
+ }
+
+ @Test
+ fun `higher priority wins when due dates tie`() {
+ val low = testTask(id = 1, title = "low", priority = Priority.LOW, due = at(100))
+ val high = testTask(id = 2, title = "high", priority = Priority.HIGH, due = at(100))
+
+ val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT)
+
+ assertThat(sorted.map { it.id }).containsExactly(2L, 1L).inOrder()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/floret/domain/TestTasks.kt b/app/src/test/java/de/jeanlucmakiola/floret/domain/TestTasks.kt
new file mode 100644
index 0000000..1d0519b
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/floret/domain/TestTasks.kt
@@ -0,0 +1,38 @@
+package de.jeanlucmakiola.floret.domain
+
+import kotlin.time.Instant
+
+/** Builds a [Task] for tests with sensible defaults; override what matters. */
+fun testTask(
+ id: Long = 1,
+ listId: Long = 1,
+ title: String = "task",
+ status: TaskStatus = TaskStatus.NEEDS_ACTION,
+ priority: Priority = Priority.NONE,
+ due: Instant? = null,
+): Task = Task(
+ id = id,
+ taskId = id,
+ listId = listId,
+ title = title,
+ description = null,
+ location = null,
+ url = null,
+ priority = priority,
+ status = status,
+ percentComplete = null,
+ start = null,
+ due = due,
+ isAllDay = false,
+ timeZone = null,
+ completedAt = null,
+ listColor = 0,
+ taskColor = null,
+ listName = null,
+ accountName = null,
+ parentId = null,
+ isRecurring = false,
+ distanceFromCurrent = 0,
+ created = null,
+ lastModified = null,
+)