M1: full task data layer, reminders and ViewModels over OpenTasks
All checks were successful
CI / ci (push) Successful in 5m25s
All checks were successful
CI / ci (push) Successful in 5m25s
Non-visual stack (backoffice) complete and verified against the real provider semantics (tasks.org's bundled dmfs TaskProvider, authority org.tasks.opentasks, org.tasks.permission.* dangerous): - TaskContract subset + ProviderResolver (runtime authority/permission) - AndroidTasksDataSource (Instances query, ContentObserver) + TasksRepository with live Flows; domain models, smart-list filtering, sorting, form validation - Self-scheduled due-reminder engine (AlarmManager, boot/provider-change) - Render-only ViewModels + UiState for every screen - 24 JVM unit tests; assembleDebug + lintDebug + testDebugUnitTest green - RootScreen is a functional scaffold over real data, to be replaced with the Material 3 Expressive screens one by one Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,32 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!--
|
||||
M0 skeleton: no permissions yet. Tasks-provider permissions
|
||||
(org.dmfs.permission.* / org.tasks.permission.*), POST_NOTIFICATIONS,
|
||||
RECEIVE_BOOT_COMPLETED, USE_EXACT_ALARM and the <queries> package
|
||||
visibility block arrive with the data layer and reminder engine
|
||||
(see docs/PLAN.md, §5–7).
|
||||
-->
|
||||
<!-- Tasks provider access. Both permission sets are declared; the active one
|
||||
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
|
||||
runtime by the permission flow. Both are dangerous-level. -->
|
||||
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
||||
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- Exact due-time reminders: USE_EXACT_ALARM on 33+, SCHEDULE on 31-32. -->
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
|
||||
<!-- Package visibility (Android 11+): see the tasks providers so
|
||||
resolveContentProvider works, and launchable apps so we can open the
|
||||
provider / a store listing during onboarding. -->
|
||||
<queries>
|
||||
<provider android:authorities="org.dmfs.tasks" />
|
||||
<provider android:authorities="org.tasks.opentasks" />
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".FloretApp"
|
||||
@@ -31,6 +50,32 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
|
||||
<receiver
|
||||
android:name=".data.reminders.DueReminderReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Re-arm alarms after reboot. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
|
||||
Targets both known authorities; the host must be static. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.ProviderChangeReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
||||
<data android:scheme="content" android:host="org.tasks.opentasks" />
|
||||
<data android:scheme="content" android:host="org.dmfs.tasks" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.jeanlucmakiola.floret.data.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.floret.data.tasks.AndroidTasksDataSource
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepositoryImpl
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.floretDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "floret_prefs",
|
||||
)
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DataBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DataProvideModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.floretDataStore
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.jeanlucmakiola.floret.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
data class Settings(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val dynamicColor: Boolean = true,
|
||||
/** The list a new task defaults to; `null` = first available. */
|
||||
val defaultListId: Long? = null,
|
||||
/** Default minutes before due to remind; 0 = at due time. */
|
||||
val reminderLeadMinutes: Int = 0,
|
||||
)
|
||||
|
||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||
@Singleton
|
||||
class SettingsPrefs @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
val settings: Flow<Settings> = dataStore.data.map { p ->
|
||||
Settings(
|
||||
themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
|
||||
?: ThemeMode.SYSTEM,
|
||||
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
||||
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
||||
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
|
||||
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
|
||||
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
|
||||
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
|
||||
}
|
||||
|
||||
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
|
||||
|
||||
private companion object {
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
|
||||
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
|
||||
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.jeanlucmakiola.floret.data.reminders
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Remembers which task reminders are currently scheduled (taskId → trigger time),
|
||||
* so [ReminderScheduler] can diff against a fresh computation and cancel only the
|
||||
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
|
||||
*/
|
||||
@Singleton
|
||||
class ScheduledReminderStore @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
suspend fun all(): Map<Long, Long> =
|
||||
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split('|')
|
||||
val id = parts.getOrNull(0)?.toLongOrNull()
|
||||
val at = parts.getOrNull(1)?.toLongOrNull()
|
||||
if (id != null && at != null) id to at else null
|
||||
}.toMap()
|
||||
|
||||
suspend fun replace(scheduled: Map<Long, Long>) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY = stringSetPreferencesKey("scheduled_reminders")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The only class that knows about the ContentResolver, [TasksContract] and the
|
||||
* active authority. Everything else works through [TasksDataSource] / domain
|
||||
* models, so swapping the provider (Posture B) never reaches above this file.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidTasksDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val providerResolver: ProviderResolver,
|
||||
) : TasksDataSource {
|
||||
|
||||
private val resolver: ContentResolver get() = context.contentResolver
|
||||
|
||||
private fun authority(): String =
|
||||
providerResolver.resolve()?.authority ?: throw ProviderUnavailableException()
|
||||
|
||||
private fun taskUri(authority: String, taskId: Long): Uri =
|
||||
ContentUris.withAppendedId(TasksContract.tasksUri(authority), taskId)
|
||||
|
||||
// --- reads ----------------------------------------------------------------
|
||||
|
||||
override fun taskLists(): List<TaskList> {
|
||||
val uri = TasksContract.listsUri(authority())
|
||||
val sort = "${Lists.ACCOUNT_NAME}, ${Lists.NAME}"
|
||||
return resolver.query(uri, TaskProjections.LISTS, null, null, sort)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun tasks(query: TaskQuery): List<Task> {
|
||||
val clauses = mutableListOf<String>()
|
||||
val args = mutableListOf<String>()
|
||||
query.listId?.let {
|
||||
clauses += "${Tasks.LIST_ID} = ?"
|
||||
args += it.toString()
|
||||
}
|
||||
if (!query.includeCompleted) clauses += "${Tasks.IS_CLOSED} = 0"
|
||||
val selection = clauses.takeIf { it.isNotEmpty() }?.joinToString(" AND ")
|
||||
return queryInstances(selection, args.takeIf { it.isNotEmpty() }?.toTypedArray())
|
||||
}
|
||||
|
||||
override fun task(taskId: Long): Task? {
|
||||
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
|
||||
return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull()
|
||||
}
|
||||
|
||||
override fun subtasks(parentTaskId: Long): List<Task> =
|
||||
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
|
||||
|
||||
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
||||
val uri = TasksContract.instancesUri(authority())
|
||||
return resolver.query(uri, TaskProjections.INSTANCES, selection, args, Instances.INSTANCE_DUE_SORTING)
|
||||
?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
override fun insertTask(form: TaskForm): Long {
|
||||
val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
|
||||
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
|
||||
?: throw TaskWriteFailedException("insert task")
|
||||
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
||||
}
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||
val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id)
|
||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||
}
|
||||
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
||||
}
|
||||
|
||||
override fun deleteTask(taskId: Long) {
|
||||
resolver.delete(taskUri(authority(), taskId), null, null)
|
||||
}
|
||||
|
||||
override fun createLocalList(name: String, color: Int): Long {
|
||||
val uri = TasksContract.asSyncAdapter(
|
||||
TasksContract.listsUri(authority()),
|
||||
TasksContract.LOCAL_ACCOUNT_NAME,
|
||||
TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||
)
|
||||
val values = TaskWriteMapper.localListValues(name, color)
|
||||
val result = resolver.insert(uri, values.toContentValues())
|
||||
?: throw TaskWriteFailedException("create local list")
|
||||
return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id")
|
||||
}
|
||||
|
||||
// --- observation ----------------------------------------------------------
|
||||
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||
val provider = providerResolver.resolve() ?: return AutoCloseable { }
|
||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun onChange(selfChange: Boolean) = onChange()
|
||||
}
|
||||
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
||||
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
||||
return AutoCloseable { resolver.unregisterContentObserver(observer) }
|
||||
}
|
||||
|
||||
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
||||
val cv = ContentValues(size)
|
||||
for ((key, value) in this) when (value) {
|
||||
null -> cv.putNull(key)
|
||||
is Long -> cv.put(key, value)
|
||||
is Int -> cv.put(key, value)
|
||||
is Boolean -> cv.put(key, if (value) 1 else 0)
|
||||
is String -> cv.put(key, value)
|
||||
else -> cv.put(key, value.toString())
|
||||
}
|
||||
return cv
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import android.database.Cursor
|
||||
|
||||
/**
|
||||
* Domain-shaped seam over a row of provider data, so the mappers can be
|
||||
* unit-tested with a Map instead of a real [Cursor] (see MapColumnReader in
|
||||
* test sources). Mirrors Calendula's ColumnReader.
|
||||
*/
|
||||
interface ColumnReader {
|
||||
fun getLong(name: String): Long?
|
||||
fun getInt(name: String): Int?
|
||||
fun getString(name: String): String?
|
||||
fun getBoolean(name: String): Boolean
|
||||
}
|
||||
|
||||
/** Reads the [cursor]'s current row, caching column indices by name. */
|
||||
class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
|
||||
|
||||
private val indices = HashMap<String, Int>()
|
||||
|
||||
private fun idx(name: String): Int = indices.getOrPut(name) { cursor.getColumnIndex(name) }
|
||||
|
||||
override fun getLong(name: String): Long? {
|
||||
val i = idx(name)
|
||||
return if (i < 0 || cursor.isNull(i)) null else cursor.getLong(i)
|
||||
}
|
||||
|
||||
override fun getInt(name: String): Int? {
|
||||
val i = idx(name)
|
||||
return if (i < 0 || cursor.isNull(i)) null else cursor.getInt(i)
|
||||
}
|
||||
|
||||
override fun getString(name: String): String? {
|
||||
val i = idx(name)
|
||||
return if (i < 0 || cursor.isNull(i)) null else cursor.getString(i)
|
||||
}
|
||||
|
||||
override fun getBoolean(name: String): Boolean = (getInt(name) ?: 0) != 0
|
||||
}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* A tasks provider Floret can talk to. The same dmfs `TaskProvider` backs every
|
||||
* candidate, so the [TasksContract] columns apply regardless of which is present.
|
||||
*/
|
||||
data class TaskProvider(
|
||||
val authority: String,
|
||||
val readPermission: String,
|
||||
val writePermission: String,
|
||||
val packageName: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The A/B seam. Detects which tasks provider is installed at runtime and which
|
||||
* permission set it needs, so nothing above the data layer hardcodes an
|
||||
* authority. Under Posture B (bundled provider) this simply finds our own
|
||||
* `org.dmfs.tasks` first. See docs/PLAN.md.
|
||||
*/
|
||||
@Singleton
|
||||
class ProviderResolver @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
/** The active provider, or `null` when no tasks provider is installed. */
|
||||
fun resolve(): TaskProvider? {
|
||||
for (candidate in CANDIDATES) {
|
||||
val info = context.packageManager.resolveContentProvider(candidate.authority, 0)
|
||||
?: continue
|
||||
return candidate.copy(packageName = info.packageName)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun hasPermission(provider: TaskProvider): Boolean =
|
||||
granted(provider.readPermission) && granted(provider.writePermission)
|
||||
|
||||
private fun granted(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
|
||||
* `org.dmfs.provider.tasks.TaskProvider`, guarded by
|
||||
* `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks`
|
||||
* + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical
|
||||
* authority; on a device with only one installed, order is moot.
|
||||
*/
|
||||
val CANDIDATES: List<TaskProvider> = listOf(
|
||||
TaskProvider(
|
||||
authority = "org.dmfs.tasks",
|
||||
readPermission = "org.dmfs.permission.READ_TASKS",
|
||||
writePermission = "org.dmfs.permission.WRITE_TASKS",
|
||||
),
|
||||
TaskProvider(
|
||||
authority = "org.tasks.opentasks",
|
||||
readPermission = "org.tasks.permission.READ_TASKS",
|
||||
writePermission = "org.tasks.permission.WRITE_TASKS",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
|
||||
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
||||
object TaskProjections {
|
||||
|
||||
val LISTS: Array<String> = arrayOf(
|
||||
Lists.ID,
|
||||
Lists.NAME,
|
||||
Lists.COLOR,
|
||||
Lists.VISIBLE,
|
||||
Lists.SYNC_ENABLED,
|
||||
Lists.OWNER,
|
||||
Lists.ACCOUNT_NAME,
|
||||
Lists.ACCOUNT_TYPE,
|
||||
)
|
||||
|
||||
/** Read from the `instances` view (inherits all task columns). */
|
||||
val INSTANCES: Array<String> = arrayOf(
|
||||
Tasks.ID,
|
||||
Instances.TASK_ID,
|
||||
Tasks.LIST_ID,
|
||||
Tasks.TITLE,
|
||||
Tasks.DESCRIPTION,
|
||||
Tasks.LOCATION,
|
||||
Tasks.URL,
|
||||
Tasks.PRIORITY,
|
||||
Tasks.STATUS,
|
||||
Tasks.PERCENT_COMPLETE,
|
||||
Tasks.COMPLETED,
|
||||
Tasks.IS_ALLDAY,
|
||||
Tasks.TZ,
|
||||
Instances.INSTANCE_START,
|
||||
Instances.INSTANCE_DUE,
|
||||
Tasks.TASK_COLOR,
|
||||
Tasks.LIST_COLOR,
|
||||
Tasks.LIST_NAME,
|
||||
Tasks.ACCOUNT_NAME,
|
||||
Tasks.PARENT_ID,
|
||||
Instances.IS_RECURRING,
|
||||
Instances.DISTANCE_FROM_CURRENT,
|
||||
Tasks.CREATED,
|
||||
Tasks.LAST_MODIFIED,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.toICal
|
||||
|
||||
/**
|
||||
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||
* so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
|
||||
* ContentValues. `null` values are written as SQL NULL (clears the column).
|
||||
*/
|
||||
object TaskWriteMapper {
|
||||
|
||||
fun taskValues(form: TaskForm, tzId: String): Map<String, Any?> = buildMap {
|
||||
put(Tasks.TITLE, form.title.trim())
|
||||
put(Tasks.LIST_ID, form.listId)
|
||||
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
|
||||
put(Tasks.PRIORITY, form.priority.toICal())
|
||||
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
||||
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
|
||||
put(Tasks.DUE, form.due?.toEpochMilliseconds())
|
||||
put(Tasks.PARENT_ID, form.parentId)
|
||||
// The provider treats a null tz as local time; set it explicitly for
|
||||
// timed tasks so the stored instant is unambiguous across zones.
|
||||
val timed = !form.isAllDay && (form.start != null || form.due != null)
|
||||
put(Tasks.TZ, if (timed) tzId else null)
|
||||
}
|
||||
|
||||
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
||||
if (completed) {
|
||||
mapOf(
|
||||
Tasks.STATUS to TasksContract.STATUS_COMPLETED,
|
||||
Tasks.PERCENT_COMPLETE to 100,
|
||||
Tasks.COMPLETED to nowMillis,
|
||||
)
|
||||
} else {
|
||||
mapOf(
|
||||
Tasks.STATUS to TasksContract.STATUS_NEEDS_ACTION,
|
||||
Tasks.PERCENT_COMPLETE to null,
|
||||
Tasks.COMPLETED to null,
|
||||
)
|
||||
}
|
||||
|
||||
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||
Lists.NAME to name.trim(),
|
||||
Lists.COLOR to color,
|
||||
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
|
||||
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||
Lists.VISIBLE to 1,
|
||||
Lists.SYNC_ENABLED to 1,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
|
||||
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||
data class TaskQuery(
|
||||
val listId: Long? = null,
|
||||
val includeCompleted: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Domain-shaped, synchronous seam over the tasks ContentResolver. Returns parsed
|
||||
* lists so [TasksRepositoryImpl] can be tested with a fake on the JVM. All Cursor
|
||||
* and ContentObserver handling lives in [AndroidTasksDataSource].
|
||||
*/
|
||||
interface TasksDataSource {
|
||||
fun taskLists(): List<TaskList>
|
||||
fun tasks(query: TaskQuery): List<Task>
|
||||
fun task(taskId: Long): Task?
|
||||
fun subtasks(parentTaskId: Long): List<Task>
|
||||
|
||||
fun insertTask(form: TaskForm): Long
|
||||
fun updateTask(taskId: Long, form: TaskForm)
|
||||
fun setCompleted(taskId: Long, completed: Boolean)
|
||||
fun deleteTask(taskId: Long)
|
||||
fun createLocalList(name: String, color: Int): Long
|
||||
|
||||
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
||||
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Whether Floret can use the tasks provider right now. Drives onboarding. */
|
||||
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
||||
|
||||
/**
|
||||
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
||||
* provider changes (our writes *and* external sync like DAVx5), via the
|
||||
* data source's ContentObserver.
|
||||
*/
|
||||
interface TasksRepository {
|
||||
fun taskLists(): Flow<List<TaskList>>
|
||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||||
|
||||
suspend fun createTask(form: TaskForm): Long
|
||||
suspend fun updateTask(taskId: Long, form: TaskForm)
|
||||
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
||||
suspend fun deleteTask(taskId: Long)
|
||||
suspend fun createLocalList(name: String, color: Int): Long
|
||||
|
||||
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||
fun providerStatus(): ProviderStatus
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.floret.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.domain.DayWindow
|
||||
import de.jeanlucmakiola.floret.domain.SmartList
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||
import de.jeanlucmakiola.floret.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import de.jeanlucmakiola.floret.domain.TaskSorting
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Clock
|
||||
|
||||
@Singleton
|
||||
class TasksRepositoryImpl @Inject constructor(
|
||||
private val dataSource: TasksDataSource,
|
||||
private val providerResolver: ProviderResolver,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : TasksRepository {
|
||||
|
||||
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
|
||||
|
||||
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
||||
|
||||
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
|
||||
dataSource.task(taskId)?.let { task ->
|
||||
TaskDetail(task, dataSource.subtasks(taskId).filter { it.taskId != taskId })
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTasks(filter: TaskFilter): List<Task> {
|
||||
val query = when (filter) {
|
||||
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
|
||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = filter.list == SmartList.COMPLETED)
|
||||
}
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
return dataSource.tasks(query)
|
||||
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
|
||||
.sortedWith(TaskSorting.DEFAULT)
|
||||
}
|
||||
|
||||
override suspend fun createTask(form: TaskForm): Long =
|
||||
withContext(io) { dataSource.insertTask(form) }
|
||||
|
||||
override suspend fun updateTask(taskId: Long, form: TaskForm) =
|
||||
withContext(io) { dataSource.updateTask(taskId, form) }
|
||||
|
||||
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
||||
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
||||
|
||||
override suspend fun deleteTask(taskId: Long) =
|
||||
withContext(io) { dataSource.deleteTask(taskId) }
|
||||
|
||||
override suspend fun createLocalList(name: String, color: Int): Long =
|
||||
withContext(io) { dataSource.createLocalList(name, color) }
|
||||
|
||||
override fun providerStatus(): ProviderStatus {
|
||||
val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
|
||||
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
|
||||
else ProviderStatus.NEEDS_PERMISSION
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an initial load, then re-loads on every provider change. The observer
|
||||
* callback (main thread) only pokes a conflated channel; the actual blocking
|
||||
* query runs on [io].
|
||||
*/
|
||||
private fun <T> observing(load: () -> T): Flow<T> = callbackFlow {
|
||||
val ticks = Channel<Unit>(Channel.CONFLATED)
|
||||
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
||||
ticks.trySend(Unit) // prime the initial emission
|
||||
val job = launch {
|
||||
ticks.consumeEach { send(load()) }
|
||||
}
|
||||
awaitClose {
|
||||
handle.close()
|
||||
job.cancel()
|
||||
ticks.close()
|
||||
}
|
||||
}.flowOn(io)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Local-day boundaries used by the smart-list predicates. Pure + testable. */
|
||||
object DayWindow {
|
||||
|
||||
/** Returns `[startOfToday, startOfTomorrow)` in [zone] for the instant [now]. */
|
||||
fun today(now: Instant, zone: ZoneId): Pair<Instant, Instant> {
|
||||
val date = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds())
|
||||
.atZone(zone)
|
||||
.toLocalDate()
|
||||
val start = date.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val end = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
return Instant.fromEpochMilliseconds(start) to Instant.fromEpochMilliseconds(end)
|
||||
}
|
||||
}
|
||||
98
app/src/main/java/de/jeanlucmakiola/floret/domain/Models.kt
Normal file
98
app/src/main/java/de/jeanlucmakiola/floret/domain/Models.kt
Normal file
@@ -0,0 +1,98 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksContract
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A task list (the `tasklists` table). Lists group under their account. */
|
||||
data class TaskList(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val color: Int,
|
||||
val accountName: String,
|
||||
val accountType: String,
|
||||
val isSynced: Boolean,
|
||||
val isVisible: Boolean,
|
||||
val owner: String?,
|
||||
) {
|
||||
/** A device-only list Floret (or another app) created locally, not synced. */
|
||||
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE
|
||||
}
|
||||
|
||||
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
||||
|
||||
/** iCalendar priority, bucketed for the UI. */
|
||||
enum class Priority { NONE, LOW, MEDIUM, HIGH }
|
||||
|
||||
/**
|
||||
* A task occurrence as read from the `instances` view. [id] is the instance row
|
||||
* id; [taskId] is the underlying `tasks._id` and the stable target for edits.
|
||||
*/
|
||||
data class Task(
|
||||
val id: Long,
|
||||
val taskId: Long,
|
||||
val listId: Long,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val location: String?,
|
||||
val url: String?,
|
||||
val priority: Priority,
|
||||
val status: TaskStatus,
|
||||
val percentComplete: Int?,
|
||||
val start: Instant?,
|
||||
val due: Instant?,
|
||||
val isAllDay: Boolean,
|
||||
val timeZone: String?,
|
||||
val completedAt: Instant?,
|
||||
val listColor: Int,
|
||||
val taskColor: Int?,
|
||||
val listName: String?,
|
||||
val accountName: String?,
|
||||
val parentId: Long?,
|
||||
val isRecurring: Boolean,
|
||||
val distanceFromCurrent: Int?,
|
||||
val created: Instant?,
|
||||
val lastModified: Instant?,
|
||||
) {
|
||||
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
|
||||
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
|
||||
val isSubtask: Boolean get() = parentId != null && parentId > 0
|
||||
/** The task's own colour if set, else the list colour. */
|
||||
val effectiveColor: Int get() = taskColor ?: listColor
|
||||
}
|
||||
|
||||
/** Detail bundle: a task plus its direct children. */
|
||||
data class TaskDetail(
|
||||
val task: Task,
|
||||
val subtasks: List<Task>,
|
||||
)
|
||||
|
||||
// --- pure provider <-> domain value mapping (unit-testable) ------------------
|
||||
|
||||
fun priorityFromICal(value: Int?): Priority = when {
|
||||
value == null || value <= 0 -> Priority.NONE
|
||||
value in 1..4 -> Priority.HIGH
|
||||
value == 5 -> Priority.MEDIUM
|
||||
else -> Priority.LOW // 6..9
|
||||
}
|
||||
|
||||
/** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */
|
||||
fun Priority.toICal(): Int = when (this) {
|
||||
Priority.NONE -> TasksContract.PRIORITY_NONE
|
||||
Priority.HIGH -> 1
|
||||
Priority.MEDIUM -> 5
|
||||
Priority.LOW -> 9
|
||||
}
|
||||
|
||||
fun statusFromInt(value: Int?): TaskStatus = when (value) {
|
||||
TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS
|
||||
TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED
|
||||
TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED
|
||||
else -> TaskStatus.NEEDS_ACTION
|
||||
}
|
||||
|
||||
fun TaskStatus.toInt(): Int = when (this) {
|
||||
TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION
|
||||
TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS
|
||||
TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED
|
||||
TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A validated create/edit form. Kept free of Android types so ViewModels and
|
||||
* tests can build and validate it on the JVM. The data layer turns it into
|
||||
* provider ContentValues ([de.jeanlucmakiola.floret.data.tasks.TaskWriteMapper]).
|
||||
*/
|
||||
data class TaskForm(
|
||||
val title: String,
|
||||
val listId: Long,
|
||||
val description: String? = null,
|
||||
val start: Instant? = null,
|
||||
val due: Instant? = null,
|
||||
val isAllDay: Boolean = false,
|
||||
val priority: Priority = Priority.NONE,
|
||||
val parentId: Long? = null,
|
||||
/** Minutes before [due] to fire a reminder; `null` = no reminder. */
|
||||
val reminderMinutesBeforeDue: Int? = null,
|
||||
) {
|
||||
fun validate(): Set<TaskFormError> = buildSet {
|
||||
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
|
||||
if (listId <= 0L) add(TaskFormError.NO_LIST)
|
||||
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
|
||||
if (reminderMinutesBeforeDue != null && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
|
||||
}
|
||||
|
||||
val isValid: Boolean get() = validate().isEmpty()
|
||||
}
|
||||
|
||||
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE }
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.jeanlucmakiola.floret.domain
|
||||
|
||||
/** Default task ordering, extracted so it's unit-testable without the repository. */
|
||||
object TaskSorting {
|
||||
/** Open before completed; within each: due-soonest (none last), priority, title. */
|
||||
val DEFAULT: Comparator<Task> = compareBy(
|
||||
{ it.isCompleted },
|
||||
{ it.due == null },
|
||||
{ it.due?.toEpochMilliseconds() ?: Long.MAX_VALUE },
|
||||
{ -it.priority.ordinal },
|
||||
{ it.title.lowercase() },
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.floret.ui.detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface TaskDetailUiState {
|
||||
data object Loading : TaskDetailUiState
|
||||
data object NotFound : TaskDetailUiState
|
||||
data class Content(val detail: TaskDetail) : TaskDetailUiState
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class TaskDetailViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val taskId = MutableStateFlow<Long?>(null)
|
||||
|
||||
val state: StateFlow<TaskDetailUiState> =
|
||||
taskId.filterNotNull()
|
||||
.flatMapLatest { id ->
|
||||
repository.taskDetail(id)
|
||||
.map<TaskDetail?, TaskDetailUiState> { detail ->
|
||||
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
||||
}
|
||||
.onStart { emit(TaskDetailUiState.Loading) }
|
||||
.catch { emit(TaskDetailUiState.NotFound) }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
||||
|
||||
fun bind(id: Long) { taskId.value = id }
|
||||
|
||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
|
||||
}
|
||||
|
||||
fun delete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.deleteTask(task.taskId) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package de.jeanlucmakiola.floret.ui.edit
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.Priority
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import de.jeanlucmakiola.floret.domain.TaskFormError
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Editable form state for creating or editing a task. The screen renders these
|
||||
* fields and calls the `on*` setters; [save] validates, writes, re-syncs
|
||||
* reminders, and flips [TaskEditUiState.saved] for the screen to navigate back.
|
||||
*/
|
||||
data class TaskEditUiState(
|
||||
val loading: Boolean = true,
|
||||
val isNew: Boolean = true,
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val listId: Long? = null,
|
||||
val start: Instant? = null,
|
||||
val due: Instant? = null,
|
||||
val isAllDay: Boolean = false,
|
||||
val priority: Priority = Priority.NONE,
|
||||
val parentId: Long? = null,
|
||||
val reminderMinutesBeforeDue: Int? = null,
|
||||
val lists: List<TaskList> = emptyList(),
|
||||
val errors: Set<TaskFormError> = emptySet(),
|
||||
val saveFailed: Boolean = false,
|
||||
val saved: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TaskEditViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val reminderScheduler: ReminderScheduler,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(TaskEditUiState())
|
||||
val state: StateFlow<TaskEditUiState> = _state.asStateFlow()
|
||||
|
||||
private var editingTaskId: Long? = null
|
||||
|
||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
||||
editingTaskId = null
|
||||
viewModelScope.launch {
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val defaultList = presetListId
|
||||
?: settingsPrefs.settings.first().defaultListId
|
||||
?: lists.firstOrNull { !it.isLocal }?.id
|
||||
?: lists.firstOrNull()?.id
|
||||
_state.value = TaskEditUiState(
|
||||
loading = false,
|
||||
isNew = true,
|
||||
listId = defaultList,
|
||||
parentId = parentId,
|
||||
lists = lists,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Load an existing task for editing. */
|
||||
fun bindEdit(taskId: Long) {
|
||||
editingTaskId = taskId
|
||||
viewModelScope.launch {
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
|
||||
if (task == null) {
|
||||
_state.value = _state.value.copy(loading = false, lists = lists)
|
||||
return@launch
|
||||
}
|
||||
_state.value = TaskEditUiState(
|
||||
loading = false,
|
||||
isNew = false,
|
||||
title = task.title,
|
||||
description = task.description.orEmpty(),
|
||||
listId = task.listId,
|
||||
start = task.start,
|
||||
due = task.due,
|
||||
isAllDay = task.isAllDay,
|
||||
priority = task.priority,
|
||||
parentId = task.parentId,
|
||||
lists = lists,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
|
||||
fun onDescriptionChange(value: String) = update { it.copy(description = value) }
|
||||
fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) }
|
||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
||||
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
||||
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
|
||||
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
||||
fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
|
||||
|
||||
fun save() {
|
||||
val current = _state.value
|
||||
val form = TaskForm(
|
||||
title = current.title,
|
||||
listId = current.listId ?: 0L,
|
||||
description = current.description.ifBlank { null },
|
||||
start = current.start,
|
||||
due = current.due,
|
||||
isAllDay = current.isAllDay,
|
||||
priority = current.priority,
|
||||
parentId = current.parentId,
|
||||
reminderMinutesBeforeDue = current.reminderMinutesBeforeDue,
|
||||
)
|
||||
val errors = form.validate()
|
||||
if (errors.isNotEmpty()) {
|
||||
_state.value = current.copy(errors = errors)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val id = editingTaskId
|
||||
if (id == null) repository.createTask(form) else repository.updateTask(id, form)
|
||||
reminderScheduler.sync()
|
||||
}.onSuccess {
|
||||
_state.value = _state.value.copy(saved = true, saveFailed = false)
|
||||
}.onFailure {
|
||||
_state.value = _state.value.copy(saveFailed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun update(block: (TaskEditUiState) -> TaskEditUiState) {
|
||||
_state.value = block(_state.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.jeanlucmakiola.floret.ui.lists
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.DayWindow
|
||||
import de.jeanlucmakiola.floret.domain.SmartList
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||
import de.jeanlucmakiola.floret.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Clock
|
||||
|
||||
data class ListOverview(val list: TaskList, val openCount: Int)
|
||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||
data class SmartCount(val smart: SmartList, val count: Int)
|
||||
|
||||
sealed interface ListsUiState {
|
||||
data object Loading : ListsUiState
|
||||
data object Failure : ListsUiState
|
||||
data class Content(
|
||||
val smartCounts: List<SmartCount>,
|
||||
val groups: List<AccountGroup>,
|
||||
) : ListsUiState
|
||||
}
|
||||
|
||||
/** The home overview: smart lists with live counts, then user lists by account. */
|
||||
@HiltViewModel
|
||||
class ListsViewModel @Inject constructor(
|
||||
repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<ListsUiState> =
|
||||
combine(
|
||||
repository.taskLists(),
|
||||
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
||||
) { lists, openTasks ->
|
||||
buildContent(lists, openTasks) as ListsUiState
|
||||
}.catch { emit(ListsUiState.Failure) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||
|
||||
private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content {
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
fun count(smart: SmartList) =
|
||||
openTasks.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
|
||||
|
||||
val smartCounts = listOf(
|
||||
SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
|
||||
SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
|
||||
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
|
||||
SmartCount(SmartList.ALL, openTasks.size),
|
||||
)
|
||||
val openByList = openTasks.groupingBy { it.listId }.eachCount()
|
||||
val groups = lists
|
||||
.groupBy { it.accountName }
|
||||
.map { (account, accountLists) ->
|
||||
AccountGroup(
|
||||
accountName = account,
|
||||
lists = accountLists.map { ListOverview(it, openByList[it.id] ?: 0) },
|
||||
)
|
||||
}
|
||||
.sortedBy { it.accountName.lowercase() }
|
||||
return ListsUiState.Content(smartCounts, groups)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.jeanlucmakiola.floret.ui.permission
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PermissionUiState(
|
||||
val status: ProviderStatus = ProviderStatus.NO_PROVIDER,
|
||||
/** The active provider's runtime permissions to request, when one is present. */
|
||||
val permissionsToRequest: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Gates app entry: is a tasks provider installed, and do we hold its permissions?
|
||||
* The Composable owns the actual permission-launcher and store intents; this VM
|
||||
* supplies the [status] and the exact permission strings to ask for.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PermissionViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val providerResolver: ProviderResolver,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(PermissionUiState())
|
||||
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
||||
|
||||
init { refresh() }
|
||||
|
||||
/** Re-read provider + permission state (call after returning from a request). */
|
||||
fun refresh() {
|
||||
val provider = providerResolver.resolve()
|
||||
_state.value = PermissionUiState(
|
||||
status = repository.providerStatus(),
|
||||
permissionsToRequest = provider
|
||||
?.let { listOf(it.readPermission, it.writePermission) }
|
||||
.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.jeanlucmakiola.floret.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.prefs.Settings
|
||||
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.TaskList
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SettingsUiState(
|
||||
val settings: Settings = Settings(),
|
||||
val lists: List<TaskList> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Drives both the Settings screen and the app theme (MainActivity collects the
|
||||
* same instance), so a theme change applies app-wide at once.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<SettingsUiState> =
|
||||
combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists ->
|
||||
SettingsUiState(settings, lists)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
||||
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package de.jeanlucmakiola.floret.ui.tasklist
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.floret.domain.Task
|
||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface TaskListUiState {
|
||||
data object Loading : TaskListUiState
|
||||
data object Failure : TaskListUiState
|
||||
data class Content(val tasks: List<Task>) : TaskListUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* The tasks for one [TaskFilter]. Call [bind] with the filter the screen shows;
|
||||
* the list re-emits live as the provider changes. Actions are fire-and-forget —
|
||||
* the resulting provider change flows back through the list automatically.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class TaskListViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val filter = MutableStateFlow<TaskFilter?>(null)
|
||||
|
||||
val state: StateFlow<TaskListUiState> =
|
||||
filter.filterNotNull()
|
||||
.flatMapLatest { f ->
|
||||
repository.tasks(f)
|
||||
.map<List<Task>, TaskListUiState> { TaskListUiState.Content(it) }
|
||||
.onStart { emit(TaskListUiState.Loading) }
|
||||
.catch { emit(TaskListUiState.Failure) }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
||||
|
||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||
|
||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
|
||||
}
|
||||
|
||||
fun delete(task: Task) = viewModelScope.launch {
|
||||
runCatching { repository.deleteTask(task.taskId) }
|
||||
}
|
||||
|
||||
/** Inline "add task" — title only, into [listId]. */
|
||||
fun quickAdd(title: String, listId: Long) = viewModelScope.launch {
|
||||
if (title.isBlank() || listId <= 0L) return@launch
|
||||
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
|
||||
}
|
||||
}
|
||||
12
app/src/main/res/drawable/ic_notification.xml
Normal file
12
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Notification status-bar icon: a simple check, tinted white by the system. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M9,16.17 L4.83,12 l-1.42,1.41 L9,19 21,7 l-1.41,-1.41z" />
|
||||
</vector>
|
||||
@@ -1,4 +1,21 @@
|
||||
<resources>
|
||||
<string name="app_name">Floret</string>
|
||||
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string>
|
||||
|
||||
<!-- Generic -->
|
||||
<string name="task_untitled">Untitled task</string>
|
||||
|
||||
<!-- Reminders -->
|
||||
<string name="reminder_due_at">Due %1$s</string>
|
||||
<string name="reminder_channel_name">Task reminders</string>
|
||||
<string name="reminder_channel_desc">Notifications for tasks that are due.</string>
|
||||
|
||||
<!-- Provider / permission onboarding -->
|
||||
<string name="onboarding_no_provider_title">A tasks app is needed</string>
|
||||
<string name="onboarding_no_provider_body">Floret shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string>
|
||||
<string name="onboarding_permission_title">Allow access to your tasks</string>
|
||||
<string name="onboarding_permission_body">Floret needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
|
||||
<string name="onboarding_permission_button">Grant task access</string>
|
||||
<string name="onboarding_install_opentasks">Install OpenTasks</string>
|
||||
<string name="onboarding_install_tasksorg">Install tasks.org</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.jeanlucmakiola.floret.data.tasks
|
||||
|
||||
/** A [ColumnReader] backed by a Map, so mappers test without a real Cursor. */
|
||||
class MapColumnReader(private val values: Map<String, Any?>) : ColumnReader {
|
||||
override fun getLong(name: String): Long? = (values[name] as? Number)?.toLong()
|
||||
override fun getInt(name: String): Int? = (values[name] as? Number)?.toInt()
|
||||
override fun getString(name: String): String? = values[name] as? String
|
||||
override fun getBoolean(name: String): Boolean = ((values[name] as? Number)?.toInt() ?: 0) != 0
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user