M1: full task data layer, reminders and ViewModels over OpenTasks
All checks were successful
CI / ci (push) Successful in 5m25s

Non-visual stack (backoffice) complete and verified against the real
provider semantics (tasks.org's bundled dmfs TaskProvider, authority
org.tasks.opentasks, org.tasks.permission.* dangerous):

- TaskContract subset + ProviderResolver (runtime authority/permission)
- AndroidTasksDataSource (Instances query, ContentObserver) + TasksRepository
  with live Flows; domain models, smart-list filtering, sorting, form validation
- Self-scheduled due-reminder engine (AlarmManager, boot/provider-change)
- Render-only ViewModels + UiState for every screen
- 24 JVM unit tests; assembleDebug + lintDebug + testDebugUnitTest green
- RootScreen is a functional scaffold over real data, to be replaced with the
  Material 3 Expressive screens one by one

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Luc Makiola
2026-06-17 21:04:19 +02:00
parent 01d1f558b8
commit bfcfc50cf4
47 changed files with 2502 additions and 40 deletions

View File

@@ -2,13 +2,32 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
M0 skeleton: no permissions yet. Tasks-provider permissions
(org.dmfs.permission.* / org.tasks.permission.*), POST_NOTIFICATIONS,
RECEIVE_BOOT_COMPLETED, USE_EXACT_ALARM and the <queries> package
visibility block arrive with the data layer and reminder engine
(see docs/PLAN.md, §57).
-->
<!-- Tasks provider access. Both permission sets are declared; the active one
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
runtime by the permission flow. Both are dangerous-level. -->
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exact due-time reminders: USE_EXACT_ALARM on 33+, SCHEDULE on 31-32. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Package visibility (Android 11+): see the tasks providers so
resolveContentProvider works, and launchable apps so we can open the
provider / a store listing during onboarding. -->
<queries>
<provider android:authorities="org.dmfs.tasks" />
<provider android:authorities="org.tasks.opentasks" />
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:name=".FloretApp"
@@ -31,6 +50,32 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
<receiver
android:name=".data.reminders.DueReminderReceiver"
android:exported="false" />
<!-- Re-arm alarms after reboot. -->
<receiver
android:name=".data.reminders.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
Targets both known authorities; the host must be static. -->
<receiver
android:name=".data.reminders.ProviderChangeReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data android:scheme="content" android:host="org.tasks.opentasks" />
<data android:scheme="content" android:host="org.dmfs.tasks" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<Preferences> 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<Preferences> =
context.floretDataStore
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}

View File

@@ -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

View File

@@ -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<Preferences>,
) {
val settings: Flow<Settings> = 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")
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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<Preferences>,
) {
suspend fun all(): Map<Long, Long> =
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<Long, Long>) {
dataStore.edit { prefs ->
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
}
}
private companion object {
val KEY = stringSetPreferencesKey("scheduled_reminders")
}
}

View File

@@ -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
}
}

View File

@@ -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<TaskList> {
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<Task> {
val clauses = mutableListOf<String>()
val args = mutableListOf<String>()
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<Task> =
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
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<String, Any?>.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
}
}

View File

@@ -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<String, Int>()
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
}

View File

@@ -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")

View File

@@ -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<TaskProvider> = 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",
),
)
}
}

View File

@@ -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),
)
}

View File

@@ -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<String> = 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<String> = 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,
)
}

View File

@@ -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<String, Any?> = 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<String, Any?> =
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<String, Any?> = 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,
)
}

View File

@@ -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()
}

View File

@@ -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<TaskList>
fun tasks(query: TaskQuery): List<Task>
fun task(taskId: Long): Task?
fun subtasks(parentTaskId: Long): List<Task>
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
}

View File

@@ -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<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
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
}

View File

@@ -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<List<TaskList>> = observing { dataSource.taskLists() }
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
dataSource.task(taskId)?.let { task ->
TaskDetail(task, dataSource.subtasks(taskId).filter { it.taskId != taskId })
}
}
private fun loadTasks(filter: TaskFilter): List<Task> {
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 <T> observing(load: () -> T): Flow<T> = callbackFlow {
val ticks = Channel<Unit>(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)
}

View File

@@ -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<Instant, Instant> {
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)
}
}

View File

@@ -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<Task>,
)
// --- 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
}

View File

@@ -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
}
}
}

View File

@@ -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<TaskFormError> = 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 }

View File

@@ -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<Task> = compareBy(
{ it.isCompleted },
{ it.due == null },
{ it.due?.toEpochMilliseconds() ?: Long.MAX_VALUE },
{ -it.priority.ordinal },
{ it.title.lowercase() },
)
}

View File

@@ -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),
)
}

View File

@@ -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<Long?>(null)
val state: StateFlow<TaskDetailUiState> =
taskId.filterNotNull()
.flatMapLatest { id ->
repository.taskDetail(id)
.map<TaskDetail?, TaskDetailUiState> { 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) }
}
}

View File

@@ -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<TaskList> = emptyList(),
val errors: Set<TaskFormError> = 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<TaskEditUiState> = _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)
}
}

View File

@@ -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<ListOverview>)
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<SmartCount>,
val groups: List<AccountGroup>,
) : 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<ListsUiState> =
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<TaskList>, openTasks: List<Task>): 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)
}
}

View File

@@ -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<String> = 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<PermissionUiState> = _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(),
)
}
}

View File

@@ -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<TaskList> = 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<SettingsUiState> =
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) }
}

View File

@@ -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<Task>) : 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<TaskFilter?>(null)
val state: StateFlow<TaskListUiState> =
filter.filterNotNull()
.flatMapLatest { f ->
repository.tasks(f)
.map<List<Task>, 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)) }
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Notification status-bar icon: a simple check, tinted white by the system. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FF000000"
android:pathData="M9,16.17 L4.83,12 l-1.42,1.41 L9,19 21,7 l-1.41,-1.41z" />
</vector>

View File

@@ -1,4 +1,21 @@
<resources>
<string name="app_name">Floret</string>
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string>
<!-- Generic -->
<string name="task_untitled">Untitled task</string>
<!-- Reminders -->
<string name="reminder_due_at">Due %1$s</string>
<string name="reminder_channel_name">Task reminders</string>
<string name="reminder_channel_desc">Notifications for tasks that are due.</string>
<!-- Provider / permission onboarding -->
<string name="onboarding_no_provider_title">A tasks app is needed</string>
<string name="onboarding_no_provider_body">Floret shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string>
<string name="onboarding_permission_title">Allow access to your tasks</string>
<string name="onboarding_permission_body">Floret needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
<string name="onboarding_permission_button">Grant task access</string>
<string name="onboarding_install_opentasks">Install OpenTasks</string>
<string name="onboarding_install_tasksorg">Install tasks.org</string>
</resources>

View File

@@ -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<String, Any?>) : 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
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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,
)