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