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:
15
CHANGELOG.md
15
CHANGELOG.md
@@ -7,6 +7,21 @@ the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- M1 data layer + logic ("backoffice"): the full non-visual stack over the
|
||||||
|
OpenTasks `TaskContract` provider (authority resolved at runtime —
|
||||||
|
`org.tasks.opentasks` for tasks.org, `org.dmfs.tasks` for OpenTasks).
|
||||||
|
- Vendored `TaskContract` subset, `ProviderResolver`, `ColumnReader`, mappers,
|
||||||
|
`AndroidTasksDataSource` (Instances query + ContentObserver), and
|
||||||
|
`TasksRepository` exposing live Flows of lists/tasks/detail.
|
||||||
|
- Domain models, smart-list filtering (Today/Upcoming/Overdue/No-date/All/
|
||||||
|
Completed), sorting, form validation, sub-tasks via `parent_id`.
|
||||||
|
- Self-scheduled due-reminder engine (AlarmManager + boot/provider-change
|
||||||
|
re-sync), notifications, DataStore prefs.
|
||||||
|
- Render-only ViewModels + UiState for every screen (lists, task list, detail,
|
||||||
|
edit, settings, permission) so the UI is build-only.
|
||||||
|
- 24 JVM unit tests (mappers, filtering, sorting, form, value mapping, day
|
||||||
|
windows). `RootScreen` is a functional scaffold over the real data, to be
|
||||||
|
replaced screen-by-screen with the Material 3 Expressive design.
|
||||||
- M0 skeleton: project scaffolding copied from Calendula (Gradle, version
|
- M0 skeleton: project scaffolding copied from Calendula (Gradle, version
|
||||||
catalog, Hilt, Material 3 Expressive theme, Gitea CI/release workflows),
|
catalog, Hilt, Material 3 Expressive theme, Gitea CI/release workflows),
|
||||||
reseeded to a warm-mauve palette. Builds and shows a themed placeholder.
|
reseeded to a warm-mauve palette. Builds and shows a themed placeholder.
|
||||||
|
|||||||
@@ -2,13 +2,32 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<!--
|
<!-- Tasks provider access. Both permission sets are declared; the active one
|
||||||
M0 skeleton: no permissions yet. Tasks-provider permissions
|
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
|
||||||
(org.dmfs.permission.* / org.tasks.permission.*), POST_NOTIFICATIONS,
|
runtime by the permission flow. Both are dangerous-level. -->
|
||||||
RECEIVE_BOOT_COMPLETED, USE_EXACT_ALARM and the <queries> package
|
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
||||||
visibility block arrive with the data layer and reminder engine
|
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
||||||
(see docs/PLAN.md, §5–7).
|
<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
|
<application
|
||||||
android:name=".FloretApp"
|
android:name=".FloretApp"
|
||||||
@@ -31,6 +50,32 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
package de.jeanlucmakiola.floret
|
package de.jeanlucmakiola.floret
|
||||||
|
|
||||||
import android.app.Application
|
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.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"
|
* Application entry point. Registered as android:name=".FloretApp". Besides
|
||||||
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
* 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
|
@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
|
package de.jeanlucmakiola.floret
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.floret.ui.RootScreen
|
import de.jeanlucmakiola.floret.ui.RootScreen
|
||||||
|
import de.jeanlucmakiola.floret.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.floret.ui.theme.FloretTheme
|
import de.jeanlucmakiola.floret.ui.theme.FloretTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single activity. M0 hosts only a themed placeholder scaffold; the task
|
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
|
||||||
* screens, theme-mode wiring (SettingsViewModel) and notification/widget
|
* (replaceable) functional scaffold over the real data layer. Task-detail intent
|
||||||
* intent routing land in later milestones — mirroring Calendula's MainActivity.
|
* routing for reminder taps lands with the full UI.
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -22,9 +30,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
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())
|
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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.floret.R
|
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
|
* ⚠️ Functional scaffold, not final UI. It proves the data layer end to end
|
||||||
* scaffold render. Replaced in M1 by the lists overview + nav host
|
* (provider gating → live lists/tasks → toggle/add) and is the canvas to replace
|
||||||
* (FloretHost), the analog of Calendula's CalendarHost.
|
* 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
|
@Composable
|
||||||
fun RootScreen(modifier: Modifier = Modifier) {
|
fun RootScreen(
|
||||||
Scaffold(modifier = modifier) { inner ->
|
modifier: Modifier = Modifier,
|
||||||
Column(
|
permissionViewModel: PermissionViewModel = hiltViewModel(),
|
||||||
modifier = Modifier
|
) {
|
||||||
.fillMaxSize()
|
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
|
||||||
.padding(inner)
|
val launcher = rememberLauncherForActivityResult(
|
||||||
.padding(24.dp),
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
) { permissionViewModel.refresh() }
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
Scaffold(
|
||||||
Text(
|
modifier = modifier,
|
||||||
text = stringResource(R.string.app_name),
|
topBar = { TopAppBar(title = { Text(stringResource(R.string.app_name)) }) },
|
||||||
style = MaterialTheme.typography.displaySmall,
|
) { inner ->
|
||||||
color = MaterialTheme.colorScheme.primary,
|
Column(Modifier.padding(inner).fillMaxSize()) {
|
||||||
)
|
when (permission.status) {
|
||||||
Text(
|
ProviderStatus.NO_PROVIDER -> Gate(
|
||||||
text = stringResource(R.string.app_tagline),
|
title = stringResource(R.string.onboarding_no_provider_title),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
body = stringResource(R.string.onboarding_no_provider_body),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
)
|
||||||
textAlign = TextAlign.Center,
|
ProviderStatus.NEEDS_PERMISSION -> Gate(
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
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>
|
<resources>
|
||||||
<string name="app_name">Floret</string>
|
<string name="app_name">Floret</string>
|
||||||
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</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>
|
</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