8 Commits

Author SHA1 Message Date
2c457f3915 release: 0.2.0
All checks were successful
CI / ci (push) Successful in 6m18s
Release — F-Droid repo + Gitea release / ci (push) Successful in 7m21s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 6m14s
Promote [Unreleased] → [0.2.0] in the changelog (M2 UI + M3/M4 + fixes) and
bump the dev-default version to 0.2.0 / 200. The pushed tag drives the released
version per docs/RELEASING.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:27:06 +02:00
e0c56a73e2 M4: percent-complete, conflict-safe saves & subtask reparent
All checks were successful
CI / ci (push) Successful in 6m49s
M3 detail/edit polish:
- "Progress" slider on the edit form writes Tasks.PERCENT_COMPLETE (clamped
  0-100, 5% detents); status stays owned by the complete toggle.
- Conflict-safe saves: updateTask re-checks the provider's last_modified against
  the value captured when the form loaded and throws TaskConflictException; the
  editor offers overwrite-or-cancel instead of clobbering an external change.

M4 subtasks (UI):
- Reparent: a "Parent task" picker files a task under any top-level task in its
  list (or "None" to promote it); candidates stay top-level to keep nesting one
  level deep. Switching list clears the now-invalid parent.
- Tapping a subtask opens its own detail (a new TaskDetail entry, own VM).

Tests: percent clamping, parent-id write, populatedFields reveal logic.
Docs: ROADMAP M2/M3/M4 reconciled to the codebase; CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:15:15 +02:00
3db553da85 detail: always show the subtasks group so add is reachable
All checks were successful
CI / ci (push) Successful in 8m38s
Render the subtasks group on every task, not just ones that already
have children, so the inline add field is available from the start.
Hide the done/total count until there's at least one subtask.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:12 +02:00
11f9649dd7 M3: rebuild task create/edit form in Calendula's card language
Replace the boxed-text-field form with the family's tonal-card layout:
Close + filled SAVE top bar, a borderless headline title with a
list-colour accent bar, a "When" card (all-day switch + tappable
start/due rows), a tappable list card and reminder card that open
OptionCard dialogs, and segmented-button priority.

Optional Description / Priority / Reminder sections unfold from a
"More fields" picker (Calendula's disclosure): TaskFormField enum +
populatedFields() auto-reveal fields that already carry a value, and a
persisted defaultEditFields pref decides which start open (the settings
UI to edit it lands with M6). New shared InlineTextField + OptionCard;
date/time conversion helpers in DateTimeField promoted to internal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:02 +02:00
3397e57794 UI: fix subtask progress chip on the task list
Count a parent's subtask progress over the full task set in the
repository (attached to Task as subtaskTotal/subtaskDone) so completed
children are counted even on smart lists that hide closed tasks — the
chip showed "0 / 2" instead of "18 / 20". List membership (and the home
overview counts) are unchanged; only the parent's derived progress is.

Also wrap the task-row metadata in a FlowRow so the subtask chip drops
to a further line when a dated task with subtasks can't fit due + chips
on one line, rather than squeezing the trailing chip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:25:08 +02:00
24cf8fe331 nav: spring slide/fade screen transitions (Calendula feel)
Drive the NavHost with the motion scheme's fastSpatialSpec: forward fades in
over a held, static background; back (and the predictive-back drag) slides the
top screen out to the right, revealing the screen beneath unmoved — the same
overlay feel as Calendula, instead of the default navigation-compose transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:03:45 +02:00
a19b1373a4 M2: build out task list, detail & edit screens
Replace the base scaffolds with the real Material 3 Expressive UI on the
existing M1 ViewModels.

Task list: due-date section headers (collapsible Completed), grouped tonal
rows with a list-colour accent bar, swipe-to-complete / -delete, inline add,
list-name chip and subtask "done/total" chip in mixed views (children folded
into their parent). New pure TaskSections grouping + unit test.

Detail: each fact its own tonal card with a leading icon (Calendula pattern) —
when/list/priority/progress/description; progress shows only with subtasks.
Subtasks are a connected grouped run whose first segment is the expand toggle,
with an accent-tinted inline add row; completed subtasks sink to the bottom.

Edit: title, description, list dropdown, start/due date-time + all-day,
priority segmented buttons, reminder offset, validation; IME auto-scroll.

Extract GroupedSurface from GroupedRow so the connected-row look is reusable
at any width; add ListNameChip + date-time format/picker helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:03:37 +02:00
b196d9ebe0 M2: wire navigation host + base screen scaffolds
Replace the RootScreen nav stubs with a real NavHost. Each destination
binds its existing M1 ViewModel from route args.

- ui/navigation: Dest route table (string-based; no serialization plugin)
  + FloretNavHost (lists -> task list -> detail / edit).
- Base scaffolds for TaskListScreen, TaskDetailScreen, TaskEditScreen,
  wired to their ViewModels — skeletons for the rich M2/M3 UI.
- RootScreen READY branch now hands off to FloretNavHost.
- Add navigation-compose 2.9.0; supporting strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:44:18 +02:00
34 changed files with 3941 additions and 133 deletions

View File

@@ -6,6 +6,38 @@ the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
## [Unreleased] ## [Unreleased]
## [0.2.0] - 2026-06-27
### Added
- M2 Material 3 Expressive UI — the app is fully navigable now: the
provider/permission onboarding gate, the lists overview (smart lists + user
lists grouped by account), and the task list (swipe-to-complete / -delete,
inline add, smart-list section headers), detail, and create/edit screens, all
wired to the M1 ViewModels.
- M3 detail/edit polish: a "Progress" slider (percent-complete, 5% detents,
written to `Tasks.PERCENT_COMPLETE`) and conflict-safe saves — `updateTask`
re-checks the provider's `last_modified` against the value captured when the
form loaded and surfaces an overwrite-or-cancel prompt instead of clobbering an
external change (e.g. a DAVx5 sync).
- M4 subtasks (UI): reparent — a full-width, searchable "Parent task" sheet on
the edit form, with candidates grouped by due-date section, files a task under
any active top-level task in its list (or "None" to promote it); switching list
clears the now-invalid parent. Tapping a subtask in the detail screen opens its
own detail, which shows a "Part of …" parent card. On the task list a parent has
a dedicated expand button that reveals its children as a nested grouped run,
ending with an inline "add a subtask" row (an opt-out toggle is planned for the
M6 settings screen).
- Priority is coloured by level (green / amber / red pastels) on the list, detail,
and edit screens.
### Fixed
- Overview open-counts now count top-level tasks only, so subtasks — and
especially open subtasks under a completed parent — no longer inflate a list's
"N open" or the smart-list counts.
- Swipe-to-delete now reveals its red background + icon as you drag (tracking the
live direction, not just the settled target), and a floating "Deleted · Undo"
chip defers the actual delete so it can be restored.
## [0.1.0] - 2026-06-18 ## [0.1.0] - 2026-06-18
### Added ### Added

View File

@@ -28,8 +28,8 @@ android {
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local // (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md. // default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 100 versionCode = 200
versionName = "0.1.0" versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -110,6 +110,7 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)

View File

@@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.floret.domain.TaskFormField
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@@ -21,6 +23,8 @@ data class Settings(
val defaultListId: Long? = null, val defaultListId: Long? = null,
/** Default minutes before due to remind; 0 = at due time. */ /** Default minutes before due to remind; 0 = at due time. */
val reminderLeadMinutes: Int = 0, val reminderLeadMinutes: Int = 0,
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
) )
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */ /** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@@ -35,6 +39,9 @@ class SettingsPrefs @Inject constructor(
dynamicColor = p[DYNAMIC_COLOR] ?: true, dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 }, defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0, reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
) )
} }
@@ -46,10 +53,15 @@ class SettingsPrefs @Inject constructor(
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes } suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
private companion object { private companion object {
val THEME_MODE = stringPreferencesKey("theme_mode") val THEME_MODE = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id") val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes") val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
} }
} }

View File

@@ -7,3 +7,10 @@ class ProviderUnavailableException :
/** A ContentResolver write returned no URI or affected no rows. */ /** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) : class TaskWriteFailedException(operation: String) :
RuntimeException("Task write failed: $operation") RuntimeException("Task write failed: $operation")
/**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
* it, so saving would clobber that change. The UI offers to overwrite or reload.
*/
class TaskConflictException(taskId: Long) :
RuntimeException("Task $taskId changed since it was loaded")

View File

@@ -17,6 +17,27 @@ object TaskWriteMapper {
put(Tasks.LIST_ID, form.listId) put(Tasks.LIST_ID, form.listId)
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null }) put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
put(Tasks.PRIORITY, form.priority.toICal()) put(Tasks.PRIORITY, form.priority.toICal())
// Keep completion in sync with progress whenever the form carries a
// percent (the Progress field was used). The provider already auto-
// completes at 100% but won't reopen below it — left to itself it strands
// a task "done at 75%" — so we set status both ways. COMPLETED is only
// cleared on reopen; at 100% we leave it for the provider to fill/keep so
// re-saving a finished task doesn't churn its completion timestamp. When
// no percent is present the standalone complete toggle stays authoritative.
val percent = form.percentComplete?.coerceIn(0, 100)
put(Tasks.PERCENT_COMPLETE, percent)
when {
percent == null -> Unit
percent >= 100 -> put(Tasks.STATUS, TasksContract.STATUS_COMPLETED)
percent > 0 -> {
put(Tasks.STATUS, TasksContract.STATUS_IN_PROCESS)
put(Tasks.COMPLETED, null)
}
else -> {
put(Tasks.STATUS, TasksContract.STATUS_NEEDS_ACTION)
put(Tasks.COMPLETED, null)
}
}
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0) put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
put(Tasks.DTSTART, form.start?.toEpochMilliseconds()) put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.toEpochMilliseconds()) put(Tasks.DUE, form.due?.toEpochMilliseconds())

View File

@@ -6,6 +6,7 @@ import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.floret.domain.TaskList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
/** Whether Floret can use the tasks provider right now. Drives onboarding. */ /** Whether Floret can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER } enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
@@ -21,7 +22,14 @@ interface TasksRepository {
fun taskDetail(taskId: Long): Flow<TaskDetail?> fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, form: TaskForm)
/**
* Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the
* task's current `last_modified` is re-checked first and a
* [TaskConflictException] is thrown if it differs — i.e. something changed it
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
*/
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean) suspend fun setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long) suspend fun deleteTask(taskId: Long)
suspend fun createLocalList(name: String, color: Int): Long suspend fun createLocalList(name: String, color: Int): Long

View File

@@ -2,7 +2,6 @@ package de.jeanlucmakiola.floret.data.tasks
import de.jeanlucmakiola.floret.data.di.IoDispatcher import de.jeanlucmakiola.floret.data.di.IoDispatcher
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.floret.domain.DayWindow
import de.jeanlucmakiola.floret.domain.SmartList
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.floret.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.floret.domain.TaskFilter
@@ -23,6 +22,7 @@ import java.time.ZoneId
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant
@Singleton @Singleton
class TasksRepositoryImpl @Inject constructor( class TasksRepositoryImpl @Inject constructor(
@@ -37,26 +37,56 @@ class TasksRepositoryImpl @Inject constructor(
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing { override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
dataSource.task(taskId)?.let { task -> dataSource.task(taskId)?.let { task ->
TaskDetail(task, dataSource.subtasks(taskId).filter { it.taskId != taskId }) TaskDetail(
task = task,
subtasks = dataSource.subtasks(taskId).filter { it.taskId != taskId },
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
)
} }
} }
private fun loadTasks(filter: TaskFilter): List<Task> { private fun loadTasks(filter: TaskFilter): List<Task> {
val query = when (filter) { val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true) is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
is TaskFilter.Smart -> TaskQuery(includeCompleted = filter.list == SmartList.COMPLETED) // Read completed tasks too, then let [TaskFiltering.matches] enforce each
// smart list's own open/closed semantics. We over-read on purpose so a
// parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
} }
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
return dataSource.tasks(query) val all = dataSource.tasks(query)
// (done, total) of direct children per parent, over the unfiltered set.
val progress: Map<Long, Pair<Int, Int>> = all
.filter { (it.parentId ?: 0L) > 0L }
.groupingBy { it.parentId!! }
.fold(0 to 0) { (done, total), t ->
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) } .filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
} ?: task
}
.sortedWith(TaskSorting.DEFAULT) .sortedWith(TaskSorting.DEFAULT)
} }
override suspend fun createTask(form: TaskForm): Long = override suspend fun createTask(form: TaskForm): Long =
withContext(io) { dataSource.insertTask(form) } withContext(io) { dataSource.insertTask(form) }
override suspend fun updateTask(taskId: Long, form: TaskForm) = override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) { dataSource.updateTask(taskId, form) } withContext(io) {
// Conflict-safe overwrite: re-read just before writing and bail if the
// provider's last_modified moved since the form captured it (external
// sync / another app). A null baseline means "force / overwrite anyway".
if (expectedLastModified != null) {
val current = dataSource.task(taskId)?.lastModified
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
}
dataSource.updateTask(taskId, form)
}
override suspend fun setCompleted(taskId: Long, completed: Boolean) = override suspend fun setCompleted(taskId: Long, completed: Boolean) =
withContext(io) { dataSource.setCompleted(taskId, completed) } withContext(io) { dataSource.setCompleted(taskId, completed) }

View File

@@ -52,6 +52,13 @@ data class Task(
val distanceFromCurrent: Int?, val distanceFromCurrent: Int?,
val created: Instant?, val created: Instant?,
val lastModified: Instant?, val lastModified: Instant?,
/**
* Direct-subtask progress, filled in by the repository over the *full* task
* set so a parent's progress chip counts completed children even on smart
* lists that hide them. Both 0 when the task has no subtasks.
*/
val subtaskTotal: Int = 0,
val subtaskDone: Int = 0,
) { ) {
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
@@ -60,10 +67,11 @@ data class Task(
val effectiveColor: Int get() = taskColor ?: listColor val effectiveColor: Int get() = taskColor ?: listColor
} }
/** Detail bundle: a task plus its direct children. */ /** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
data class TaskDetail( data class TaskDetail(
val task: Task, val task: Task,
val subtasks: List<Task>, val subtasks: List<Task>,
val parent: Task? = null,
) )
// --- pure provider <-> domain value mapping (unit-testable) ------------------ // --- pure provider <-> domain value mapping (unit-testable) ------------------

View File

@@ -16,6 +16,8 @@ data class TaskForm(
val isAllDay: Boolean = false, val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE, val priority: Priority = Priority.NONE,
val parentId: Long? = null, val parentId: Long? = null,
/** 0..100 progress; `null` leaves it unset (cleared). */
val percentComplete: Int? = null,
/** Minutes before [due] to fire a reminder; `null` = no reminder. */ /** Minutes before [due] to fire a reminder; `null` = no reminder. */
val reminderMinutesBeforeDue: Int? = null, val reminderMinutesBeforeDue: Int? = null,
) { ) {
@@ -30,3 +32,20 @@ data class TaskForm(
} }
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE } enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE }
/**
* Optional edit-form sections that can be shown or tucked behind "More fields"
* (the core Title / List / When are always visible). Which render by default is
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
* disclosure generic, so adding a field later is one entry plus its card.
*/
enum class TaskFormField { Description, Priority, Progress, Parent, Reminder }
/** The optional fields that already carry a value — auto-revealed when editing. */
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
if (!description.isNullOrBlank()) add(TaskFormField.Description)
if (priority != Priority.NONE) add(TaskFormField.Priority)
if ((percentComplete ?: 0) > 0) add(TaskFormField.Progress)
if (parentId != null && parentId > 0) add(TaskFormField.Parent)
if (reminderMinutesBeforeDue != null) add(TaskFormField.Reminder)
}

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.floret.domain
import kotlin.time.Instant
/** A due-date bucket a task list groups its rows under. */
enum class TaskSection { OVERDUE, TODAY, UPCOMING, NO_DATE, COMPLETED }
/** A non-empty run of tasks under one [TaskSection], already in display order. */
data class SectionedTasks(val section: TaskSection, val tasks: List<Task>)
/**
* Group already-sorted tasks into due-date sections (the standard task-app
* presentation). Pure + side-effect-free so it unit-tests with a fixed clock,
* mirroring [TaskFiltering] / [DayWindow]. Completed tasks always fall to the
* [TaskSection.COMPLETED] bucket regardless of their due date.
*
* [todayStart] is local midnight today and [todayEnd] local midnight tomorrow
* (compute with [DayWindow]). Only non-empty sections are returned, in the
* fixed order Overdue → Today → Upcoming → No date → Completed.
*/
object TaskSections {
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant): List<SectionedTasks> {
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) }
return TaskSection.entries.mapNotNull { section ->
bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) }
}
}
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
if (task.isCompleted) return TaskSection.COMPLETED
val due = task.due ?: return TaskSection.NO_DATE
return when {
due < todayStart -> TaskSection.OVERDUE
due < todayEnd -> TaskSection.TODAY
else -> TaskSection.UPCOMING
}
}
}

View File

@@ -19,13 +19,12 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.ui.lists.ListsScreen import de.jeanlucmakiola.floret.ui.navigation.FloretNavHost
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel
/** /**
* App root: gates on the tasks-provider permission, then shows the home * App root: gates on the tasks-provider permission, then hands off to
* ([ListsScreen]). Navigation to the task-list / edit screens is wired as those * [FloretNavHost] (lists → task list → detail / edit).
* screens land (next milestones); the callbacks are stubs for now.
*/ */
@Composable @Composable
fun RootScreen( fun RootScreen(
@@ -50,11 +49,7 @@ fun RootScreen(
action = stringResource(R.string.onboarding_permission_button), action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
) )
ProviderStatus.READY -> ListsScreen( ProviderStatus.READY -> FloretNavHost(modifier = modifier)
modifier = modifier,
onOpenFilter = { /* TODO: navigate to TaskListScreen */ },
onNewTask = { /* TODO: navigate to TaskEditScreen */ },
)
} }
} }

View File

@@ -0,0 +1,166 @@
package de.jeanlucmakiola.floret.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.jeanlucmakiola.floret.R
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
private val zone: ZoneId get() = ZoneId.systemDefault()
internal fun Instant.toLocalDate(): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
internal fun Instant.toLocalTime(): LocalTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
/**
* A labelled date(-time) field for the edit form: a tonal row showing the
* current value (or nothing), tappable to pick a date and — unless [allDay] —
* a time. A clear affordance appears once a value is set. Emits `null` when
* cleared. Styled to match the app's rounded tonal family.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimeField(
label: String,
value: Instant?,
allDay: Boolean,
onChange: (Instant?) -> Unit,
modifier: Modifier = Modifier,
) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
Surface(
onClick = { showDatePicker = true },
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
style = MaterialTheme.typography.bodyLarge,
)
}
if (value != null) {
IconButton(onClick = { onChange(null) }) {
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
}
}
}
}
if (showDatePicker) {
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
showDatePicker = false
val millis = dateState.selectedDateMillis ?: return@TextButton
val date = java.time.Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) {
onChange(localToInstant(date, LocalTime.MIDNIGHT))
} else {
pendingDate = date
showTimePicker = true
}
},
) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
) { DatePicker(state = dateState) }
}
if (showTimePicker) {
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
val timeState = rememberTimePickerState(
initialHour = base.toLocalTime().hour,
initialMinute = base.toLocalTime().minute,
)
Dialog(onDismissRequest = { showTimePicker = false }) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TimePicker(state = timeState)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
TextButton(onClick = {
showTimePicker = false
val date = pendingDate ?: return@TextButton
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
}) { Text(stringResource(android.R.string.ok)) }
}
}
}
}
}
}

View File

@@ -0,0 +1,28 @@
package de.jeanlucmakiola.floret.ui.common
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.time.Instant
/**
* Locale-aware display formatting for the [kotlin.time.Instant]s the domain
* uses. Kept tiny and dependency-free; the data layer stores instants, the UI
* renders them in the device's zone and locale.
*/
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private fun Instant.atSystemZone() =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(ZoneId.systemDefault())
/** Medium localized date, e.g. "18 Jun 2026". */
fun Instant.formatDate(): String = atSystemZone().format(dateFormatter)
/** Short localized time, e.g. "14:30". */
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
/** Date alone for all-day items, otherwise date + time. */
fun Instant.formatDateTime(allDay: Boolean): String =
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}"

View File

@@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -36,10 +37,51 @@ fun positionOf(index: Int, count: Int): Position = when {
else -> Position.Middle else -> Position.Middle
} }
/** Corner radii for a grouped segment: full at the group's outer edges, small between. */
fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(topStart = full, topEnd = full, bottomStart = small, bottomEnd = small)
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
}
/** /**
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose * One segment of a grouped list: a tonal [Surface] whose corner radii come from
* corner radii come from its [position] (so a run of rows reads as a single * [position] (so a run reads as a single rounded card), with a small gap below
* rounded card). Corners morph further on press — the expressive shape play. * all but the last. Corners morph rounder on press — the expressive shape play.
* The caller supplies [content] and any horizontal inset via [modifier], so the
* same primitive serves both the 16dp-inset lists and full-width detail cards.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupedSurface(
position: Position,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
content: @Composable () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val shape = groupedShape(position, full, small)
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
val base = modifier.fillMaxWidth().then(gap)
if (onClick != null) {
Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() }
} else {
Surface(color = color, shape = shape, modifier = base) { content() }
}
}
/**
* One row in a grouped list: an M3 [ListItem] over a [GroupedSurface] (the
* tonal, position-shaped, press-morphing family container). Insets 16dp like
* the lists overview.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -54,24 +96,6 @@ fun GroupedRow(
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val shape = when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
)
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
val itemColors = if (selected) { val itemColors = if (selected) {
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -83,7 +107,17 @@ fun GroupedRow(
} else { } else {
ListItemDefaults.colors(containerColor = Color.Transparent) ListItemDefaults.colors(containerColor = Color.Transparent)
} }
val item: @Composable () -> Unit = { val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
GroupedSurface(
position = position,
modifier = modifier.padding(horizontal = 16.dp),
onClick = onClick,
color = containerColor,
) {
ListItem( ListItem(
headlineContent = { Text(title) }, headlineContent = { Text(title) },
supportingContent = summary?.let { text -> { Text(text) } }, supportingContent = summary?.let { text -> { Text(text) } },
@@ -93,24 +127,4 @@ fun GroupedRow(
modifier = Modifier.heightIn(min = minHeight), modifier = Modifier.heightIn(min = minHeight),
) )
} }
val base = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.then(gap)
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
if (onClick != null) {
Surface(
onClick = onClick,
color = containerColor,
shape = shape,
interactionSource = interaction,
modifier = base,
) { item() }
} else {
Surface(color = containerColor, shape = shape, modifier = base) { item() }
}
} }

View File

@@ -0,0 +1,83 @@
package de.jeanlucmakiola.floret.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* The app's borderless text input: no underline, no outline, just the tonal
* card behind it. Floret uses this inside a tonal [androidx.compose.material3.Surface]
* for the edit form (title + cards), so anything that takes text reads as one
* family rather than a boxed Material field. Ported from Calendula so the two
* apps share one input style.
*/
@Composable
fun InlineTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
imeAction: ImeAction = ImeAction.Default,
/** Invoked when the IME action key (e.g. Done) is pressed. */
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
capitalization = capitalization,
imeAction = imeAction,
),
keyboardActions = onImeAction?.let { action ->
KeyboardActions(onAny = { action() })
} ?: KeyboardActions.Default,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (value.isEmpty()) {
// Clearly fainter than typed text, so a hint never reads as
// prefilled content.
Text(
text = placeholder,
style = resolvedStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
innerTextField()
}
},
modifier = modifier,
)
}

View File

@@ -3,17 +3,23 @@ package de.jeanlucmakiola.floret.ui.common
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
@@ -53,3 +59,25 @@ fun ListColorChip(color: Int, modifier: Modifier = Modifier) {
) )
} }
} }
/**
* A small pill naming the task's list, filled in the list's (pastelised) colour.
* The label flips black/white by the fill's luminance so it stays legible on any
* hue in light and dark. Used in mixed views (smart lists) where rows span lists.
*/
@Composable
fun ListNameChip(name: String, color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val fill = pastelize(color, dark)
val onFill = if (fill.luminance() > 0.5f) Color.Black else Color.White
Surface(color = fill, shape = RoundedCornerShape(50), modifier = modifier) {
Text(
text = name,
color = onFill,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}

View File

@@ -0,0 +1,95 @@
package de.jeanlucmakiola.floret.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* The app's standard pick in a selection dialog: a full-width tonal card,
* optionally with a leading icon and a supporting line; the selected option is
* highlighted. Stack with 8dp gaps inside an AlertDialog — the only sanctioned
* selection-modal style (no radio rows, no bare text lists). Ported from
* Calendula so the families' dialogs read identically.
*/
@Composable
fun OptionCard(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
/** Icon tint override, e.g. a list colour; unspecified follows selection. */
iconTint: Color = Color.Unspecified,
supportingText: String? = null,
selected: Boolean = false,
/** Label colour override, e.g. primary for an emphasised entry. */
labelColor: Color = Color.Unspecified,
) {
val contentColor = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
onClick = onClick,
color = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
shape = RoundedCornerShape(12.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = when {
iconTint.isSpecified -> iconTint
selected -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(12.dp))
}
Column {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (labelColor.isSpecified) labelColor else contentColor,
)
if (supportingText != null) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}

View File

@@ -0,0 +1,109 @@
package de.jeanlucmakiola.floret.ui.common
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.domain.Priority
/**
* Priority has no semantic Material role (M3 only ships `error` for danger), so
* we give the ranked levels their own hues — green / amber / red. They're softened
* harder than the list-colour [pastelize] (lower saturation, higher value) so the
* chips stay gentle and low-key rather than shouting. [Priority.NONE] has no
* colour (callers render it neutral).
*/
fun priorityFill(priority: Priority, dark: Boolean): Color {
val base = when (priority) {
Priority.HIGH -> 0xFFE53935.toInt() // red
Priority.MEDIUM -> 0xFFFFB300.toInt() // amber
Priority.LOW -> 0xFF43A047.toInt() // green
Priority.NONE -> return Color.Unspecified
}
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(base, hsv)
hsv[1] = if (dark) 0.36f else 0.22f // desaturate toward pastel
hsv[2] = if (dark) 0.58f else 0.92f // light, soft fill in light; muted in dark
return Color(android.graphics.Color.HSVToColor(hsv))
}
/**
* A coloured status pill for a task's priority: a flag plus the level's [label],
* filled in that level's pastel hue. Used read-only in the list and detail
* screens (default [selected] = true), and as a selectable option in the edit
* form — there [selected] = false shows the hue on the flag with an outlined,
* unfilled container, and tapping commits the choice via [onClick].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PriorityChip(
priority: Priority,
label: String,
modifier: Modifier = Modifier,
selected: Boolean = true,
showIcon: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val dark = isSystemInDarkTheme()
val fill = priorityFill(priority, dark)
val hasColor = fill.isSpecified
val container: Color
val content: Color
val iconTint: Color
var border: BorderStroke? = null
when {
selected && hasColor -> {
container = fill
content = if (fill.luminance() > 0.5f) Color.Black else Color.White
iconTint = content
}
selected -> { // NONE, selected — neutral fill, no hue to show
container = MaterialTheme.colorScheme.surfaceContainerHighest
content = MaterialTheme.colorScheme.onSurfaceVariant
iconTint = content
}
else -> { // an unselected option in a picker — outline, hue kept on the flag
container = Color.Transparent
content = MaterialTheme.colorScheme.onSurfaceVariant
iconTint = if (hasColor) fill else MaterialTheme.colorScheme.onSurfaceVariant
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
}
}
val body: @Composable () -> Unit = {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showIcon) {
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(15.dp))
}
Text(label, style = MaterialTheme.typography.labelMedium, color = content)
}
}
val shape = RoundedCornerShape(50)
if (onClick != null) {
Surface(onClick = onClick, color = container, shape = shape, border = border, modifier = modifier) { body() }
} else {
Surface(color = container, shape = shape, border = border, modifier = modifier) { body() }
}
}

View File

@@ -0,0 +1,528 @@
package de.jeanlucmakiola.floret.ui.detail
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail
import de.jeanlucmakiola.floret.ui.common.GroupedSurface
import de.jeanlucmakiola.floret.ui.common.PriorityChip
import de.jeanlucmakiola.floret.ui.common.formatDate
import de.jeanlucmakiola.floret.ui.common.formatTime
import de.jeanlucmakiola.floret.ui.common.pastelize
import de.jeanlucmakiola.floret.ui.common.positionOf
import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel
/**
* One task's detail. Each fact is its own tonal card with a leading icon (the
* Calendula detail pattern); only genuinely-grouped things — the subtasks — use
* the grouped-row run. Edit / delete live in the top bar.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskDetailScreen(
onEdit: () -> Unit,
onDeleted: () -> Unit,
onBack: () -> Unit,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskDetailViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.task_detail_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = {
val content = state as? TaskDetailUiState.Content
IconButton(onClick = onEdit, enabled = content != null) {
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.edit))
}
IconButton(
onClick = {
content?.let {
viewModel.delete(it.detail.task)
onDeleted()
}
},
enabled = content != null,
) {
Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete))
}
},
)
},
) { inner ->
when (val s = state) {
TaskDetailUiState.Loading -> Unit
TaskDetailUiState.NotFound ->
CenteredMessage(stringResource(R.string.task_detail_not_found), inner)
is TaskDetailUiState.Content -> DetailBody(
detail = s.detail,
inner = inner,
onToggle = { viewModel.toggleComplete(s.detail.task) },
onToggleSubtask = { viewModel.toggleComplete(it) },
onAddSubtask = { viewModel.addSubtask(it) },
onOpenTask = onOpenTask,
)
}
}
}
@Composable
private fun DetailBody(
detail: TaskDetail,
inner: androidx.compose.foundation.layout.PaddingValues,
onToggle: () -> Unit,
onToggleSubtask: (Task) -> Unit,
onAddSubtask: (String) -> Unit,
onOpenTask: (Long) -> Unit,
) {
val task = detail.task
val dark = isSystemInDarkTheme()
val accent = pastelize(task.effectiveColor, dark)
val gap = 12.dp
var subtasksExpanded by rememberSaveable { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(inner)
// Shrink the scroll viewport by the keyboard so the focused add-subtask
// field scrolls into view above the IME instead of being hidden.
.imePadding()
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) {
// Title + complete toggle, with a short list-coloured accent beneath.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Text(
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(10.dp))
Box(
modifier = Modifier
.width(48.dp)
.height(3.dp)
.background(accent, RoundedCornerShape(2.dp)),
)
Spacer(Modifier.height(20.dp))
// One card per fact; gaps between, no shared container (they aren't a group).
val cards = buildList<@Composable () -> Unit> {
// Parent — only when this is a subtask. Conveys that it's filed under
// another task (and so doesn't stand alone in the list); taps open it.
detail.parent?.let { parent ->
add {
DetailCard(
icon = Icons.Rounded.AccountTree,
iconContentDescription = stringResource(R.string.detail_parent),
onClick = { onOpenTask(parent.taskId) },
) {
Text(
text = stringResource(R.string.detail_part_of),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = parent.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.titleMedium,
)
}
}
}
// When — the Calendula time card: date primary, time(s) secondary.
taskWhenLines(task)?.let { (primary, secondary) ->
add {
DetailCard(icon = Icons.Rounded.Schedule, iconContentDescription = stringResource(R.string.detail_due)) {
Text(primary, style = MaterialTheme.typography.titleMedium)
if (secondary != null) {
Spacer(Modifier.height(2.dp))
Text(
secondary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// List — icon tinted in the list colour conveys identity (no extra dot).
add {
DetailCard(
icon = Icons.Rounded.Checklist,
iconTint = accent,
iconContentDescription = stringResource(R.string.detail_list),
) {
Text(
text = task.listName ?: stringResource(R.string.detail_list),
style = MaterialTheme.typography.titleMedium,
)
}
}
// Priority — a coloured status chip in the level's hue.
if (task.priority != de.jeanlucmakiola.floret.domain.Priority.NONE) {
add {
DetailCard(
icon = Icons.Rounded.Flag,
iconContentDescription = stringResource(R.string.detail_priority),
) {
PriorityChip(
priority = task.priority,
label = priorityLabel(task.priority),
showIcon = false,
)
}
}
}
// Progress — shown when the task carries its own percent or has
// subtasks (then the completion ratio fills in if no percent is set).
val hasSubtasks = detail.subtasks.isNotEmpty()
if ((task.percentComplete ?: 0) > 0 || hasSubtasks) {
val pct = task.percentComplete
?: (detail.subtasks.count { it.isCompleted } * 100 / detail.subtasks.size)
add {
DetailCard(icon = Icons.Rounded.Percent, iconContentDescription = stringResource(R.string.detail_progress)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(R.string.detail_progress), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.percent_complete, pct),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(progress = { pct / 100f }, modifier = Modifier.fillMaxWidth())
}
}
}
// Description.
task.description?.takeIf { it.isNotBlank() }?.let { description ->
add {
DetailCard(icon = Icons.AutoMirrored.Rounded.Notes, iconContentDescription = null) {
Text(description, style = MaterialTheme.typography.bodyMedium)
}
}
}
// Subtasks — a genuine group: a connected grouped run whose first
// segment is the expand/collapse toggle. Always shown (even with no
// subtasks yet) so the add field is reachable on any task.
add {
SubtasksGroup(
subtasks = detail.subtasks,
expanded = subtasksExpanded,
onToggle = { subtasksExpanded = !subtasksExpanded },
onToggleSubtask = onToggleSubtask,
onAddSubtask = onAddSubtask,
onOpenSubtask = onOpenTask,
)
}
}
cards.forEachIndexed { index, card ->
if (index > 0) Spacer(Modifier.height(gap))
card()
}
}
}
/**
* Subtasks as a connected grouped run (the GroupedRow family look) at the
* cards' width: the first segment is the expand/collapse toggle (icon,
* "Subtasks", a done/total count, chevron); the rest are the subtask rows.
*/
@Composable
private fun SubtasksGroup(
subtasks: List<Task>,
expanded: Boolean,
onToggle: () -> Unit,
onToggleSubtask: (Task) -> Unit,
onAddSubtask: (String) -> Unit,
onOpenSubtask: (Long) -> Unit,
) {
val done = subtasks.count { it.isCompleted }
// Completed subtasks sink to the bottom; stable, so order is otherwise kept.
val ordered = remember(subtasks) { subtasks.sortedBy { it.isCompleted } }
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "subtasksChevron")
// Segments when expanded: header + the always-present add row + each subtask.
val count = if (expanded) subtasks.size + 2 else 1
// First segment: the toggle header.
GroupedSurface(position = positionOf(0, count), onClick = onToggle) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ListAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.detail_subtasks),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
// No count until there's something to count.
if (subtasks.isNotEmpty()) {
Text(
text = "$done / ${subtasks.size}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
Icon(
imageVector = Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
}
if (expanded) {
// Always-present add row at the top of the list, in an accent tone.
GroupedSurface(
position = positionOf(1, count),
color = MaterialTheme.colorScheme.primaryContainer,
) {
AddSubtaskField(onAdd = onAddSubtask)
}
ordered.forEachIndexed { index, sub ->
GroupedSurface(
position = positionOf(index + 2, count),
onClick = { onOpenSubtask(sub.taskId) },
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = sub.isCompleted, onCheckedChange = { onToggleSubtask(sub) })
Spacer(Modifier.width(4.dp))
Text(
text = sub.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (sub.isCompleted) TextDecoration.LineThrough else null,
color = if (sub.isCompleted) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
}
}
}
}
}
/**
* Inline "add a subtask" — title only; submits on the tick or the IME action.
* A [BasicTextField] (not the boxed Material field) so its text lines up exactly
* with the subtask titles, the checkbox column matched by the leading + icon.
*/
@Composable
private fun AddSubtaskField(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
fun submit() {
if (text.isNotBlank()) {
onAdd(text.trim())
text = ""
}
}
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Add, contentDescription = null, tint = accentOn)
}
Spacer(Modifier.width(4.dp))
BasicTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = accentOn),
cursorBrush = SolidColor(accentOn),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
modifier = Modifier.weight(1f),
decorationBox = { inner ->
if (text.isEmpty()) {
Text(
text = stringResource(R.string.add_task_hint),
style = MaterialTheme.typography.bodyLarge,
color = accentOn.copy(alpha = 0.7f),
)
}
inner()
},
)
if (text.isNotBlank()) {
IconButton(onClick = ::submit) {
Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task), tint = accentOn)
}
}
}
}
/** One info card: tonal container, leading icon in the gutter, value to the right. */
@Composable
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(16.dp)
val color = MaterialTheme.colorScheme.surfaceContainerHigh
val body: @Composable () -> Unit = {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
if (onClick != null) {
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
} else {
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
}
}
/**
* The task's "when", formatted like Calendula's event time card: a primary date
* line and an optional secondary time line. A start+due pair reads as a range;
* `null` when the task has no dates at all.
*/
@Composable
private fun taskWhenLines(task: Task): Pair<String, String?>? {
val start = task.start
val due = task.due
return when {
start != null && due != null -> {
val sameDay = start.formatDate() == due.formatDate()
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} ${due.formatDate()}"
val secondary = if (task.isAllDay) null else "${start.formatTime()} ${due.formatTime()}"
primary to secondary
}
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime()
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime()
else -> null
}
}
@Composable
private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) {
Box(
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.floret.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -54,4 +55,15 @@ class TaskDetailViewModel @Inject constructor(
fun delete(task: Task) = viewModelScope.launch { fun delete(task: Task) = viewModelScope.launch {
runCatching { repository.deleteTask(task.taskId) } runCatching { repository.deleteTask(task.taskId) }
} }
/** Add a subtask (title only) under the currently-shown task. */
fun addSubtask(title: String) = viewModelScope.launch {
val parent = (state.value as? TaskDetailUiState.Content)?.detail?.task ?: return@launch
if (title.isBlank()) return@launch
runCatching {
repository.createTask(
TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId),
)
}
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,16 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
import de.jeanlucmakiola.floret.data.tasks.TaskConflictException
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskFormError import de.jeanlucmakiola.floret.domain.TaskFormError
import de.jeanlucmakiola.floret.domain.TaskFormField
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.floret.domain.TaskList
import de.jeanlucmakiola.floret.domain.populatedFields
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -34,12 +39,24 @@ data class TaskEditUiState(
val isAllDay: Boolean = false, val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE, val priority: Priority = Priority.NONE,
val parentId: Long? = null, val parentId: Long? = null,
val percentComplete: Int? = null,
val reminderMinutesBeforeDue: Int? = null, val reminderMinutesBeforeDue: Int? = null,
val lists: List<TaskList> = emptyList(), val lists: List<TaskList> = emptyList(),
/** Top-level tasks in the current list this task can be filed under (reparent). */
val parentCandidates: List<Task> = emptyList(),
/** Optional sections currently shown (core Title / List / When are always on). */
val visibleFields: Set<TaskFormField> = emptySet(),
/** Optional sections still tucked behind "More fields". */
val hiddenFields: List<TaskFormField> = emptyList(),
val errors: Set<TaskFormError> = emptySet(), val errors: Set<TaskFormError> = emptySet(),
val saveFailed: Boolean = false, val saveFailed: Boolean = false,
/** The task changed underneath us; the screen asks to overwrite or cancel. */
val saveConflict: Boolean = false,
val saved: Boolean = false, val saved: Boolean = false,
) ) {
/** The currently-selected parent task, if this task is filed under one. */
val parent: Task? get() = parentCandidates.firstOrNull { it.taskId == parentId }
}
@HiltViewModel @HiltViewModel
class TaskEditViewModel @Inject constructor( class TaskEditViewModel @Inject constructor(
@@ -53,21 +70,33 @@ class TaskEditViewModel @Inject constructor(
private var editingTaskId: Long? = null private var editingTaskId: Long? = null
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
private var baselineLastModified: Instant? = null
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
private var defaultFields: Set<TaskFormField> = emptySet()
/** Start a fresh task, optionally pre-selecting a list / parent. */ /** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) { fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
editingTaskId = null editingTaskId = null
baselineLastModified = null
viewModelScope.launch { viewModelScope.launch {
val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val defaultList = presetListId val defaultList = presetListId
?: settingsPrefs.settings.first().defaultListId ?: settings.defaultListId
?: lists.firstOrNull { !it.isLocal }?.id ?: lists.firstOrNull { !it.isLocal }?.id
?: lists.firstOrNull()?.id ?: lists.firstOrNull()?.id
_state.value = TaskEditUiState( _state.value = withFields(
loading = false, TaskEditUiState(
isNew = true, loading = false,
listId = defaultList, isNew = true,
parentId = parentId, listId = defaultList,
lists = lists, parentId = parentId,
lists = lists,
parentCandidates = loadParents(defaultList, selfId = null),
),
) )
} }
} }
@@ -76,50 +105,92 @@ class TaskEditViewModel @Inject constructor(
fun bindEdit(taskId: Long) { fun bindEdit(taskId: Long) {
editingTaskId = taskId editingTaskId = taskId
viewModelScope.launch { viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull() val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
if (task == null) { if (task == null) {
_state.value = _state.value.copy(loading = false, lists = lists) _state.value = _state.value.copy(loading = false, lists = lists)
return@launch return@launch
} }
_state.value = TaskEditUiState( baselineLastModified = task.lastModified
loading = false, _state.value = withFields(
isNew = false, TaskEditUiState(
title = task.title, loading = false,
description = task.description.orEmpty(), isNew = false,
listId = task.listId, title = task.title,
start = task.start, description = task.description.orEmpty(),
due = task.due, listId = task.listId,
isAllDay = task.isAllDay, start = task.start,
priority = task.priority, due = task.due,
parentId = task.parentId, isAllDay = task.isAllDay,
lists = lists, priority = task.priority,
parentId = task.parentId,
percentComplete = task.percentComplete,
lists = lists,
parentCandidates = loadParents(task.listId, selfId = taskId),
),
) )
} }
} }
/**
* Tasks the edited task can be filed under: active, top-level tasks in
* [listId], minus the task itself (a task can't parent itself). Restricting to
* top-level keeps the hierarchy one level deep (matching the detail screen),
* and to active so you don't file work under something already done/cancelled.
*/
private suspend fun loadParents(listId: Long?, selfId: Long?): List<Task> {
if (listId == null) return emptyList()
val tasks = runCatching { repository.tasks(TaskFilter.OfList(listId)).first() }.getOrElse { emptyList() }
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
}
/** Reveal an optional section (one tap from the "More fields" picker). */
fun revealField(field: TaskFormField) = update {
val visible = it.visibleFields + field
it.copy(visibleFields = visible, hiddenFields = hiddenOf(visible))
}
/**
* Resolve which optional sections are visible: the default set, plus any
* field that already carries a value (so editing never hides real data).
*/
private fun withFields(state: TaskEditUiState): TaskEditUiState {
val visible = defaultFields + state.toForm().populatedFields()
return state.copy(visibleFields = visible, hiddenFields = hiddenOf(visible))
}
private fun hiddenOf(visible: Set<TaskFormField>): List<TaskFormField> =
TaskFormField.entries.filterNot { it in visible }
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) } fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
fun onDescriptionChange(value: String) = update { it.copy(description = value) } fun onDescriptionChange(value: String) = update { it.copy(description = value) }
fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) }
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
fun onListChange(listId: Long) {
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
update { it.copy(listId = listId, parentId = null, errors = emptySet()) }
viewModelScope.launch {
val candidates = loadParents(listId, selfId = editingTaskId)
update { it.copy(parentCandidates = candidates) }
}
}
fun onStartChange(value: Instant?) = update { it.copy(start = value) } fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) } fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) } fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) } fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) } fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
fun save() { /** Dismiss the conflict prompt without saving (the user keeps editing). */
fun dismissConflict() = update { it.copy(saveConflict = false) }
/** [force] = overwrite even if the task changed since it loaded (conflict prompt). */
fun save(force: Boolean = false) {
val current = _state.value val current = _state.value
val form = TaskForm( val form = current.toForm()
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() val errors = form.validate()
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
_state.value = current.copy(errors = errors) _state.value = current.copy(errors = errors)
@@ -128,12 +199,20 @@ class TaskEditViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
val id = editingTaskId val id = editingTaskId
if (id == null) repository.createTask(form) else repository.updateTask(id, form) if (id == null) {
repository.createTask(form)
} else {
repository.updateTask(id, form, expectedLastModified = if (force) null else baselineLastModified)
}
reminderScheduler.sync() reminderScheduler.sync()
}.onSuccess { }.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false) _state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
}.onFailure { }.onFailure { error ->
_state.value = _state.value.copy(saveFailed = true) _state.value = if (error is TaskConflictException) {
_state.value.copy(saveConflict = true, saveFailed = false)
} else {
_state.value.copy(saveFailed = true)
}
} }
} }
} }
@@ -142,3 +221,17 @@ class TaskEditViewModel @Inject constructor(
_state.value = block(_state.value) _state.value = block(_state.value)
} }
} }
/** Snapshot the editable state as a validated [TaskForm]. */
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
title = title,
listId = listId ?: 0L,
description = description.ifBlank { null },
start = start,
due = due,
isAllDay = isAllDay,
priority = priority,
parentId = parentId,
percentComplete = percentComplete,
reminderMinutesBeforeDue = reminderMinutesBeforeDue,
)

View File

@@ -49,16 +49,20 @@ class ListsViewModel @Inject constructor(
private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content { private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content {
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
// Count only top-level tasks: a subtask is represented by its parent (and
// its progress chip), and an open subtask under a *completed* parent must
// not read as an open item here. Mirrors the task list collapsing subtasks.
val topLevel = openTasks.filter { !it.isSubtask }
fun count(smart: SmartList) = fun count(smart: SmartList) =
openTasks.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) } topLevel.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
val smartCounts = listOf( val smartCounts = listOf(
SmartCount(SmartList.TODAY, count(SmartList.TODAY)), SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)), SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)), SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
SmartCount(SmartList.ALL, openTasks.size), SmartCount(SmartList.ALL, topLevel.size),
) )
val openByList = openTasks.groupingBy { it.listId }.eachCount() val openByList = topLevel.groupingBy { it.listId }.eachCount()
val groups = lists val groups = lists
.groupBy { it.accountName } .groupBy { it.accountName }
.map { (account, accountLists) -> .map { (account, accountLists) ->

View File

@@ -0,0 +1,88 @@
package de.jeanlucmakiola.floret.ui.navigation
import androidx.navigation.NavType
import androidx.navigation.navArgument
import de.jeanlucmakiola.floret.domain.SmartList
import de.jeanlucmakiola.floret.domain.TaskFilter
/**
* String-based route table for the app's [androidx.navigation.NavHost].
*
* Routes are kept stringly-typed (rather than the type-safe `@Serializable`
* API) so we don't have to pull in the kotlinx-serialization Gradle plugin for
* four small destinations. Each destination exposes a `route` template (for
* `composable(...)`) and a builder that fills the arguments (for `navigate`).
*
* Argument convention: optional `Long`s default to [NO_ID] (`-1`) rather than
* being nullable, which sidesteps the lack of a nullable-Long [NavType].
*/
object Dest {
const val NO_ID = -1L
/** Home — smart lists + the user's lists. */
const val LISTS = "lists"
/**
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
* `smart` (a [SmartList] name); whichever is set decides the filter.
*/
object TaskList {
const val ARG_LIST_ID = "listId"
const val ARG_SMART = "smart"
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}"
val arguments = listOf(
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
)
fun build(filter: TaskFilter): String = when (filter) {
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
}
/** Reconstruct the filter from a destination's arguments. */
fun filterOf(smart: String?, listId: Long): TaskFilter =
if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart))
else TaskFilter.OfList(listId)
}
/** A single task's detail. */
object TaskDetail {
const val ARG_TASK_ID = "taskId"
const val route = "taskDetail/{$ARG_TASK_ID}"
val arguments = listOf(navArgument(ARG_TASK_ID) { type = NavType.LongType })
fun build(taskId: Long): String = "taskDetail/$taskId"
}
/**
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
* task, `presetListId` / `parentId` optionally seed the form.
*/
object TaskEdit {
const val ARG_TASK_ID = "taskId"
const val ARG_PRESET_LIST_ID = "presetListId"
const val ARG_PARENT_ID = "parentId"
const val route =
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
"&$ARG_PARENT_ID={$ARG_PARENT_ID}"
val arguments = listOf(
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_PRESET_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
)
fun buildEdit(taskId: Long): String = "taskEdit?$ARG_TASK_ID=$taskId"
fun buildNew(presetListId: Long? = null, parentId: Long? = null): String {
val params = buildList {
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
}
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
}
}
}

View File

@@ -0,0 +1,119 @@
package de.jeanlucmakiola.floret.ui.navigation
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.ui.detail.TaskDetailScreen
import de.jeanlucmakiola.floret.ui.detail.TaskDetailViewModel
import de.jeanlucmakiola.floret.ui.edit.TaskEditScreen
import de.jeanlucmakiola.floret.ui.edit.TaskEditViewModel
import de.jeanlucmakiola.floret.ui.lists.ListsScreen
import de.jeanlucmakiola.floret.ui.tasklist.TaskListScreen
import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
/**
* The single in-app back stack: lists → task list → detail / edit.
*
* Each destination resolves its own Hilt [androidx.lifecycle.ViewModel] and
* feeds it the route arguments through that VM's `bind(...)` method (the VMs
* were built render-only in M1 and take their input this way rather than from a
* `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
* unaware of the nav library.
*/
@Composable
fun FloretNavHost(modifier: Modifier = Modifier) {
val nav = rememberNavController()
val slide = rememberNavSlideSpec()
NavHost(
navController = nav,
startDestination = Dest.LISTS,
modifier = modifier,
// Only the screen on top moves; the background is always held static.
// Forward is a plain fade-in over the held screen — calm, not a slide.
// Back (and the predictive-back drag, which drives popExit) is the snappy
// slide-out to the right, revealing the held screen unmoved.
enterTransition = { fadeIn() },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { slideOutHorizontally(slide) { it } + fadeOut() },
) {
composable(Dest.LISTS) {
ListsScreen(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
)
}
composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry ->
val args = entry.arguments
val filter = Dest.TaskList.filterOf(
smart = args?.getString(Dest.TaskList.ARG_SMART),
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
)
val vm: TaskListViewModel = hiltViewModel()
LaunchedEffect(filter) { vm.bind(filter) }
TaskListScreen(
filter = filter,
viewModel = vm,
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
onNewTask = {
val presetListId = (filter as? TaskFilter.OfList)?.listId
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId))
},
onBack = { nav.popBackStack() },
)
}
composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
val vm: TaskDetailViewModel = hiltViewModel()
LaunchedEffect(taskId) { vm.bind(taskId) }
TaskDetailScreen(
viewModel = vm,
onEdit = { nav.navigate(Dest.TaskEdit.buildEdit(taskId)) },
onDeleted = { nav.popBackStack() },
onBack = { nav.popBackStack() },
// A subtask opens its own detail — another entry on the stack, with
// its own VM bound to that id (it may have children of its own).
onOpenTask = { subtaskId -> nav.navigate(Dest.TaskDetail.build(subtaskId)) },
)
}
composable(Dest.TaskEdit.route, arguments = Dest.TaskEdit.arguments) { entry ->
val args = entry.arguments
val taskId = args?.getLong(Dest.TaskEdit.ARG_TASK_ID) ?: Dest.NO_ID
val presetListId = args?.getLong(Dest.TaskEdit.ARG_PRESET_LIST_ID) ?: Dest.NO_ID
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
val vm: TaskEditViewModel = hiltViewModel()
LaunchedEffect(taskId, presetListId, parentId) {
if (taskId != Dest.NO_ID) {
vm.bindEdit(taskId)
} else {
vm.bindNew(
presetListId = presetListId.takeIf { it != Dest.NO_ID },
parentId = parentId.takeIf { it != Dest.NO_ID },
)
}
}
TaskEditScreen(
viewModel = vm,
onSaved = { nav.popBackStack() },
onBack = { nav.popBackStack() },
)
}
}
}

View File

@@ -0,0 +1,21 @@
package de.jeanlucmakiola.floret.ui.navigation
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.IntOffset
/**
* The horizontal slide spec for screen-to-screen navigation: the *fast*
* spring-physics spec from the active motion scheme — snappy with a subtle
* springy settle, rather than a fixed easing curve. Same choice as Calendula's
* calendar slide, so the two apps move the same way.
*
* Read in composable scope so the non-composable `NavHost` transition lambdas
* can capture it.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberNavSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()

View File

@@ -0,0 +1,935 @@
package de.jeanlucmakiola.floret.ui.tasklist
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
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.domain.DayWindow
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.SmartList
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskSection
import de.jeanlucmakiola.floret.domain.TaskSections
import de.jeanlucmakiola.floret.ui.common.ListNameChip
import de.jeanlucmakiola.floret.ui.common.Position
import de.jeanlucmakiola.floret.ui.common.PriorityChip
import de.jeanlucmakiola.floret.ui.common.formatDateTime
import de.jeanlucmakiola.floret.ui.common.pastelize
import de.jeanlucmakiola.floret.ui.common.positionOf
import java.time.ZoneId
import kotlin.time.Clock
/**
* The tasks for one [TaskFilter]: due-date section headers, grouped tonal rows
* with a leading checkbox, swipe-to-complete (start→end) / swipe-to-delete
* (end→start), and — for a real list — an inline add field. Renders against the
* M1 [TaskListViewModel]; actions are fire-and-forget and flow back live.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskListScreen(
filter: TaskFilter,
onOpenTask: (Long) -> Unit,
onNewTask: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val listName = (state as? TaskListUiState.Content)?.listName
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
// gives an undo window: Undo restores it, otherwise the delete commits. A
// second delete while a chip is up commits the first (its window is over).
var undoTarget by remember { mutableStateOf<Task?>(null) }
val onRequestDelete: (Task) -> Unit = { task ->
undoTarget?.let { viewModel.commitDelete(it.taskId) }
viewModel.markPendingDelete(task)
undoTarget = task
}
LaunchedEffect(undoTarget) {
val target = undoTarget ?: return@LaunchedEffect
delay(4_000)
viewModel.commitDelete(target.taskId)
if (undoTarget?.taskId == target.taskId) undoTarget = null
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text(titleFor(filter, listName)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) },
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
}
UndoChip(
visible = undoTarget != null,
onUndo = {
undoTarget?.let { viewModel.undoDelete(it.taskId) }
undoTarget = null
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = inner.calculateBottomPadding() + 24.dp),
)
}
}
}
/**
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
* full-width snackbar) sized to its content, sliding up from the bottom centre.
*/
@Composable
private fun UndoChip(visible: Boolean, onUndo: () -> Unit, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(50),
shadowElevation = 6.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 20.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.task_deleted),
style = MaterialTheme.typography.bodyMedium,
)
TextButton(
onClick = onUndo,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.undo),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun TaskListBody(
state: TaskListUiState.Content,
filter: TaskFilter,
inner: PaddingValues,
onOpenTask: (Long) -> Unit,
onRequestDelete: (Task) -> Unit,
viewModel: TaskListViewModel,
) {
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
// A parent represents its children via a progress chip (counts come from the
// repository, over the full set), so drop children from the flat list — unless
// their parent isn't in this view (can happen in smart lists), where we keep
// them standalone so nothing silently vanishes.
val displayTasks = remember(state.tasks) {
val present = state.tasks.mapTo(HashSet()) { it.taskId }
state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
}
val sections = remember(displayTasks) { TaskSections.of(displayTasks, todayStart, todayEnd) }
val showHeaders = sections.size > 1
val listId = (filter as? TaskFilter.OfList)?.listId
// In a mixed view (a smart list) the list colour bar + name disambiguate
// rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are
// grouped from the same flat task set the list already holds.
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
val childrenByParent = remember(state.tasks) {
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = inner.calculateTopPadding() + 8.dp,
bottom = inner.calculateBottomPadding() + 96.dp,
),
) {
if (listId != null) {
item(key = "inline-add") {
InlineAdd(onAdd = { title -> viewModel.quickAdd(title, listId) })
}
}
if (state.tasks.isEmpty()) {
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
}
sections.forEach { section ->
val collapsible = showHeaders && section.section == TaskSection.COMPLETED
if (showHeaders) {
stickyHeader(key = "hdr-${section.section}") {
if (collapsible) {
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = completedExpanded,
onToggle = { completedExpanded = !completedExpanded },
)
} else {
SectionHeader(sectionLabel(section.section))
}
}
}
if (!collapsible || completedExpanded) {
// Flatten the section into one grouped run: each parent, followed
// by its subtasks when expanded. Each row's corners then come from
// its place in the whole run (positionOf below) — so the children
// read as a grouped list nested in the list's grouped list:
// full-width, connecting to the parent above (small top corners),
// and the run rounds off (full corners) only at its genuine ends.
val rows = buildList {
val topLevel = section.tasks
topLevel.forEachIndexed { p, task ->
val children = childrenByParent[task.taskId].orEmpty()
// Only expandable when we hold every child: true in a real
// list, but a smart list may omit children due on other
// days, and a half-shown set would mislead.
val canExpand = task.subtaskTotal > 0 && children.size == task.subtaskTotal
val isExpanded = canExpand && task.taskId in expandedParents
// Top-level tasks keep their own connected run — full top
// only on the first, full bottom only on the last — so the
// task after an expanded one stays mid-run (small top). An
// expanded parent's bottom opens (small) to meet its subtasks.
val parentPosition = cornerPosition(
topFull = p == 0,
bottomFull = if (isExpanded) false else p == topLevel.lastIndex,
)
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
if (isExpanded) {
// Subtasks connect up to the parent (small top) and to
// each other; the inline add row below closes the group,
// so every subtask is mid-run (small top + bottom).
children.sortedBy { it.isCompleted }.forEach { sub ->
add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = false)))
}
// The add row rounds the group off (full bottom),
// independent of whatever top-level task follows.
add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
}
}
}
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
val lastInRun = index == rows.lastIndex
when (val row = r.row) {
is ListRow.Parent -> TaskRow(
task = row.task,
position = r.position,
lastInRun = lastInRun,
showListName = showListName,
subtaskDone = row.task.subtaskDone,
subtaskTotal = row.task.subtaskTotal,
expanded = row.expanded,
onToggleExpand = if (row.expandable) {
{
expandedParents = if (row.task.taskId in expandedParents) {
expandedParents - row.task.taskId
} else {
expandedParents + row.task.taskId
}
}
} else {
null
},
onToggle = { viewModel.toggleComplete(row.task) },
onDelete = { onRequestDelete(row.task) },
onClick = { onOpenTask(row.task.taskId) },
)
is ListRow.Sub -> SubtaskRow(
task = row.task,
position = r.position,
lastInRun = lastInRun,
onToggle = { viewModel.toggleComplete(row.task) },
onClick = { onOpenTask(row.task.taskId) },
)
is ListRow.AddSub -> AddSubtaskRow(
position = r.position,
lastInRun = lastInRun,
onAdd = { viewModel.quickAddSubtask(row.parent, it) },
)
}
}
}
}
}
}
/** Inline "add a task" — title only, into the current list. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InlineAdd(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
fun submit() {
if (text.isNotBlank()) {
onAdd(text.trim())
text = ""
}
}
Surface(
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
) {
TextField(
value = text,
onValueChange = { text = it },
placeholder = { Text(stringResource(R.string.add_task_hint)) },
leadingIcon = { Icon(Icons.Rounded.Add, contentDescription = null) },
trailingIcon = {
if (text.isNotBlank()) {
IconButton(onClick = ::submit) {
Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task))
}
}
},
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { submit() }),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
modifier = Modifier.fillMaxWidth(),
)
}
}
/**
* One task row: a tonal grouped-card surface (corners from [position], morphing
* rounder on press — the app's shape family) wrapped in a swipe box. Swiping
* right toggles complete (snaps back); swiping left deletes.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TaskRow(
task: Task,
position: Position,
lastInRun: Boolean,
showListName: Boolean,
subtaskDone: Int,
subtaskTotal: Int,
expanded: Boolean,
onToggleExpand: (() -> Unit)?,
onToggle: () -> Unit,
onDelete: () -> Unit,
onClick: () -> Unit,
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
when (value) {
// Both snap back: complete toggles status; delete hands off to the
// undo flow, which removes the row from the data. Never *confirm*
// the dismiss — a terminal dismissed state strands a stale
// background bar and won't re-arm when the row is restored.
SwipeToDismissBoxValue.StartToEnd -> { onToggle(); false }
SwipeToDismissBoxValue.EndToStart -> { onDelete(); false }
SwipeToDismissBoxValue.Settled -> false
}
},
)
// A small gap below every row but the run's last — including a group-ending
// Bottom that's followed by a fresh card, so the two groups read apart.
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
SwipeToDismissBox(
state = dismissState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
// dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
) {
TaskRowContent(task, position, showListName, subtaskDone, subtaskTotal, expanded, onToggleExpand, onToggle, onClick)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun TaskRowContent(
task: Task,
position: Position,
showListName: Boolean,
subtaskDone: Int,
subtaskTotal: Int,
expanded: Boolean,
onToggleExpand: (() -> Unit)?,
onToggle: () -> Unit,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val dark = isSystemInDarkTheme()
val due = task.due?.formatDateTime(task.isAllDay)
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
val hasSupporting = due != null || listName != null || subtaskTotal > 0 ||
task.priority != Priority.NONE
Surface(
onClick = onClick,
shape = cardShape(position, full, small),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
interactionSource = interaction,
modifier = Modifier.fillMaxWidth(),
) {
// Hand-laid row (not ListItem) so a one- vs two-line body never trips
// ListItem's three-line heuristic and leaves dead space at the bottom.
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Calendula's signature accent: a slim pastelised colour bar for the
// list, paired here with the task's completion checkbox.
Box(
modifier = Modifier
.size(width = 5.dp, height = 32.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(task.effectiveColor, dark)),
)
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Spacer(Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
color = if (task.isCompleted) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
)
if (hasSupporting) {
Spacer(Modifier.height(3.dp))
// Flow so the chips wrap to a further line when due date + list
// + subtask progress can't share one (e.g. a dated task with
// subtasks), rather than squeezing the trailing chip.
FlowRow(
verticalArrangement = Arrangement.spacedBy(3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
if (task.priority != Priority.NONE) {
PriorityChip(task.priority, priorityLabel(task.priority))
}
if (due != null) {
Text(
text = due,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (listName != null) ListNameChip(listName, task.effectiveColor)
if (subtaskTotal > 0) SubtaskCountChip(subtaskDone, subtaskTotal)
}
}
}
// Trailing area is only ever the expand control, so the row's right
// edge stays put whether or not the task has children (priority now
// rides in the supporting chip row instead of a shifting trailing flag).
if (onToggleExpand != null) {
SubtaskExpandButton(expanded = expanded, onToggle = onToggleExpand)
}
}
}
}
/** A small neutral pill showing subtask progress, e.g. "3 / 5". */
@Composable
private fun SubtaskCountChip(done: Int, total: Int) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(50),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ListAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Text(
text = "$done / $total",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* The inline "add a subtask" row that closes an expanded group — a [BasicTextField]
* aligned with the subtask titles (leading + where the checkbox sits), submitting
* on the tick or the IME action. Same grouped-card segment as the rows around it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddSubtaskRow(
position: Position,
lastInRun: Boolean,
onAdd: (String) -> Unit,
) {
var text by remember { mutableStateOf("") }
fun submit() {
if (text.isNotBlank()) {
onAdd(text.trim())
text = ""
}
}
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
Surface(
shape = cardShape(position, 22.dp, 6.dp),
// Same tone as the subtask rows it closes, so the group reads as one unit.
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.width(5.dp)) // the parents' colour-bar slot, kept for alignment
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(4.dp))
BasicTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { submit() }),
modifier = Modifier.weight(1f),
decorationBox = { inner ->
if (text.isEmpty()) {
Text(
text = stringResource(R.string.add_subtask_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
inner()
},
)
if (text.isNotBlank()) {
IconButton(onClick = ::submit) {
Icon(
Icons.Rounded.Check,
contentDescription = stringResource(R.string.cd_add_task),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
/** The trailing expand/collapse control for a parent row — a rotating chevron. */
@Composable
private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) {
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "subtaskExpand")
IconButton(onClick = onToggle) {
Icon(
imageVector = Icons.Rounded.ExpandMore,
contentDescription = stringResource(
if (expanded) R.string.cd_collapse_subtasks else R.string.cd_expand_subtasks,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
}
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
private sealed interface ListRow {
val key: Long
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
override val key: Long get() = task.taskId
}
data class Sub(val task: Task) : ListRow {
override val key: Long get() = task.taskId
}
/** The inline "add a subtask" row that closes an expanded group. */
data class AddSub(val parent: Task) : ListRow {
// Negative so it never collides with a real (positive) provider task id.
override val key: Long get() = -parent.taskId
}
}
/** A flattened row paired with the grouped-card corners it should render with. */
private data class RenderRow(val row: ListRow, val position: Position)
/**
* Map independent top/bottom corner choices to a [Position]. `full` = the big
* outer corner radius (a run's start/end); otherwise the small connecting radius.
* Letting each edge be chosen on its own lets a subtask group round off (full
* bottom) while the next top-level task keeps a small top and stays mid-run.
*/
private fun cornerPosition(topFull: Boolean, bottomFull: Boolean): Position = when {
topFull && bottomFull -> Position.Alone
topFull -> Position.Top
bottomFull -> Position.Bottom
else -> Position.Middle
}
/**
* One subtask row inside an expanded parent: a full-width grouped-card segment
* (same tonal surface, corners-from-[position], press-morph as the task rows), so
* it's part of the same grouped run. The only difference is a leading indent
* before the checkbox to convey nesting — the card width is preserved.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SubtaskRow(
task: Task,
position: Position,
lastInRun: Boolean,
onToggle: () -> Unit,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "subFullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "subSmallCorner")
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
Surface(
onClick = onClick,
shape = cardShape(position, full, small),
// A step below the parents' surfaceContainerHigh so the subtask group
// reads as a distinct, recessive tone — the nesting cue alongside shape.
color = MaterialTheme.colorScheme.surfaceContainer,
interactionSource = interaction,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = 12.dp, end = 16.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// No colour bar — that absence (with the grouped nesting) marks the row
// as a subtask. Reserve the bar's 5dp so the checkbox still lines up
// with the parent rows' checkboxes rather than indenting.
Spacer(Modifier.width(5.dp))
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Spacer(Modifier.width(4.dp))
Text(
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
color = if (task.isCompleted) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
}
}
}
/** The colored reveal behind a swiping row: complete on the left, delete on the right. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SwipeBackground(target: SwipeToDismissBoxValue, isCompleted: Boolean) {
val (color, icon, desc, alignment) = when (target) {
SwipeToDismissBoxValue.StartToEnd -> SwipeStyle(
MaterialTheme.colorScheme.primaryContainer,
Icons.Rounded.Check,
if (isCompleted) R.string.cd_reopen else R.string.cd_complete,
Alignment.CenterStart,
)
SwipeToDismissBoxValue.EndToStart -> SwipeStyle(
MaterialTheme.colorScheme.errorContainer,
Icons.Rounded.Delete,
R.string.delete,
Alignment.CenterEnd,
)
SwipeToDismissBoxValue.Settled -> SwipeStyle(
Color.Transparent, Icons.Rounded.Check, R.string.cd_complete, Alignment.CenterStart,
)
}
Surface(color = color, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), contentAlignment = alignment) {
Icon(icon, contentDescription = stringResource(desc))
}
}
}
private data class SwipeStyle(
val color: Color,
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val descRes: Int,
val alignment: Alignment,
)
private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, small: androidx.compose.ui.unit.Dp): Shape =
when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(topStart = full, topEnd = full, bottomStart = small, bottomEnd = small)
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
}
@Composable
private fun SectionHeader(text: String) {
// Opaque surface so rows scroll cleanly under the pinned header.
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/** A tappable section header with a count and a chevron that rotates on expand. */
@Composable
private fun CollapsibleSectionHeader(
label: String,
count: Int,
expanded: Boolean,
onToggle: () -> Unit,
) {
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "chevron")
Surface(
onClick = onToggle,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(start = 28.dp, end = 20.dp, top = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
Text(
text = count.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
}
}
/** Icon + message for an empty list — the Calendula agenda-empty pattern. */
@Composable
private fun EmptyState(text: String) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 64.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Rounded.Checklist,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(16.dp))
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun CenteredMessage(text: String, inner: PaddingValues) {
Box(
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
is TaskFilter.Smart -> stringResource(
when (filter.list) {
SmartList.TODAY -> R.string.smart_today
SmartList.UPCOMING -> R.string.smart_upcoming
SmartList.OVERDUE -> R.string.smart_overdue
SmartList.ALL -> R.string.smart_all
SmartList.NO_DATE -> R.string.smart_no_date
SmartList.COMPLETED -> R.string.smart_completed
},
)
}
@Composable
private fun sectionLabel(section: TaskSection): String = stringResource(
when (section) {
TaskSection.OVERDUE -> R.string.section_overdue
TaskSection.TODAY -> R.string.section_today
TaskSection.UPCOMING -> R.string.section_upcoming
TaskSection.NO_DATE -> R.string.section_no_date
TaskSection.COMPLETED -> R.string.section_completed
},
)
@Composable
internal fun priorityLabel(priority: Priority): String = stringResource(
when (priority) {
Priority.NONE -> R.string.priority_none
Priority.LOW -> R.string.priority_low
Priority.MEDIUM -> R.string.priority_medium
Priority.HIGH -> R.string.priority_high
},
)

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -23,7 +24,13 @@ import javax.inject.Inject
sealed interface TaskListUiState { sealed interface TaskListUiState {
data object Loading : TaskListUiState data object Loading : TaskListUiState
data object Failure : TaskListUiState data object Failure : TaskListUiState
data class Content(val tasks: List<Task>) : TaskListUiState
/**
* [listName] is the real list's name when the filter is a
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
* the screen falls back to the smart label in that case.
*/
data class Content(val tasks: List<Task>, val listName: String? = null) : TaskListUiState
} }
/** /**
@@ -39,11 +46,30 @@ class TaskListViewModel @Inject constructor(
private val filter = MutableStateFlow<TaskFilter?>(null) private val filter = MutableStateFlow<TaskFilter?>(null)
/** Tasks swiped to delete but not yet committed — hidden until the undo lapses. */
private val pendingDeletes = MutableStateFlow<Set<Long>>(emptySet())
val state: StateFlow<TaskListUiState> = val state: StateFlow<TaskListUiState> =
filter.filterNotNull() filter.filterNotNull()
.flatMapLatest { f -> .flatMapLatest { f ->
repository.tasks(f) val tasks = repository.tasks(f)
.map<List<Task>, TaskListUiState> { TaskListUiState.Content(it) } val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
is TaskFilter.OfList ->
combine(tasks, repository.taskLists()) { list, lists ->
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name)
}
is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) }
}
// Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit.
combine(content, pendingDeletes) { st, pending ->
if (st is TaskListUiState.Content) {
st.copy(tasks = st.tasks.filter { it.taskId !in pending })
} else {
st
}
}
.onStart { emit(TaskListUiState.Loading) } .onStart { emit(TaskListUiState.Loading) }
.catch { emit(TaskListUiState.Failure) } .catch { emit(TaskListUiState.Failure) }
} }
@@ -55,8 +81,24 @@ class TaskListViewModel @Inject constructor(
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
} }
fun delete(task: Task) = viewModelScope.launch { /** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
runCatching { repository.deleteTask(task.taskId) } fun markPendingDelete(task: Task) {
pendingDeletes.value = pendingDeletes.value + task.taskId
}
/** Undo — stop hiding the task; it returns to the list. */
fun undoDelete(taskId: Long) {
pendingDeletes.value = pendingDeletes.value - taskId
}
/**
* Commit the delete once the undo window passes. The id stays in the pending
* set: the task is gone from the provider so it can't reappear, and removing
* it could briefly un-hide the row before the provider change propagates.
*/
fun commitDelete(taskId: Long) = viewModelScope.launch {
if (taskId !in pendingDeletes.value) return@launch
runCatching { repository.deleteTask(taskId) }
} }
/** Inline "add task" — title only, into [listId]. */ /** Inline "add task" — title only, into [listId]. */
@@ -64,4 +106,14 @@ class TaskListViewModel @Inject constructor(
if (title.isBlank() || listId <= 0L) return@launch if (title.isBlank() || listId <= 0L) return@launch
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) } runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
} }
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
if (title.isBlank() || parent.listId <= 0L) return@launch
runCatching {
repository.createTask(
TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId),
)
}
}
} }

View File

@@ -4,6 +4,105 @@
<!-- Generic --> <!-- Generic -->
<string name="task_untitled">Untitled task</string> <string name="task_untitled">Untitled task</string>
<string name="back">Back</string>
<string name="save">Save</string>
<string name="delete">Delete</string>
<!-- Task list -->
<string name="tasks_title">Tasks</string>
<string name="task_list_empty">No tasks here yet.</string>
<string name="task_list_failure">Could not load these tasks.</string>
<string name="smart_no_date">No date</string>
<string name="smart_completed">Completed</string>
<!-- Task detail -->
<string name="task_detail_title">Task</string>
<string name="task_detail_not_found">This task no longer exists.</string>
<string name="edit">Edit</string>
<!-- Task edit -->
<string name="edit_task_title">Edit task</string>
<string name="new_task_title">New task</string>
<string name="field_title">Title</string>
<string name="field_description">Description</string>
<string name="edit_save_failed">Could not save. Try again.</string>
<!-- Task list: inline add + swipe -->
<string name="add_task_hint">Add a task</string>
<string name="add_subtask_hint">Add a subtask</string>
<string name="cd_add_task">Add task</string>
<string name="cd_complete">Complete</string>
<string name="cd_reopen">Reopen</string>
<string name="task_deleted">Deleted</string>
<string name="undo">Undo</string>
<string name="cd_expand_subtasks">Show subtasks</string>
<string name="cd_collapse_subtasks">Hide subtasks</string>
<!-- Sections (due-date buckets) -->
<string name="section_overdue">Overdue</string>
<string name="section_today">Today</string>
<string name="section_upcoming">Upcoming</string>
<string name="section_no_date">No date</string>
<string name="section_completed">Completed</string>
<!-- Priority -->
<string name="priority_none">None</string>
<string name="priority_low">Low</string>
<string name="priority_medium">Medium</string>
<string name="priority_high">High</string>
<!-- Task detail -->
<string name="detail_mark_complete">Mark complete</string>
<string name="detail_mark_incomplete">Mark not complete</string>
<string name="detail_list">List</string>
<string name="detail_due">Due</string>
<string name="detail_start">Starts</string>
<string name="detail_priority">Priority</string>
<string name="detail_progress">Progress</string>
<string name="detail_subtasks">Subtasks</string>
<string name="detail_parent">Parent task</string>
<string name="detail_part_of">Part of</string>
<string name="percent_complete">%1$d%%</string>
<!-- Task edit -->
<string name="edit_list_label">List</string>
<string name="edit_start_label">Starts</string>
<string name="edit_due_label">Due</string>
<string name="edit_all_day">All day</string>
<string name="edit_priority_label">Priority</string>
<string name="edit_progress_label">Progress</string>
<string name="edit_parent_label">Parent task</string>
<string name="edit_parent_none">None (top-level)</string>
<string name="edit_parent_search">Search tasks</string>
<string name="edit_parent_empty">No matching tasks in this list.</string>
<string name="edit_reminder_label">Reminder</string>
<string name="edit_set">Set</string>
<string name="edit_clear">Clear</string>
<string name="edit_title_hint">Task title</string>
<string name="edit_when">When</string>
<string name="edit_more_fields">More fields</string>
<string name="close">Close</string>
<string name="dialog_cancel">Cancel</string>
<!-- Save conflict -->
<string name="edit_conflict_title">Task changed elsewhere</string>
<string name="edit_conflict_body">This task was updated somewhere else (a sync or another app) since you opened it. Overwrite those changes with your edits?</string>
<string name="edit_conflict_overwrite">Overwrite</string>
<!-- Reminder offsets -->
<string name="reminder_none">None</string>
<string name="reminder_at_due">At time of task</string>
<string name="reminder_5_min">5 minutes before</string>
<string name="reminder_10_min">10 minutes before</string>
<string name="reminder_30_min">30 minutes before</string>
<string name="reminder_1_hour">1 hour before</string>
<string name="reminder_1_day">1 day before</string>
<!-- Form validation -->
<string name="error_blank_title">Enter a title.</string>
<string name="error_no_list">Choose a list.</string>
<string name="error_due_before_start">Due can\'t be before the start.</string>
<string name="error_reminder_without_due">Set a due date to use a reminder.</string>
<!-- Lists overview --> <!-- Lists overview -->
<string name="lists_header">Lists</string> <string name="lists_header">Lists</string>

View File

@@ -28,6 +28,50 @@ class TaskWriteMapperTest {
assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin") assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin")
} }
@Test
fun `percent complete is written and clamped to 0-100`() {
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 140),
tzId = "UTC",
)
assertThat(values[Tasks.PERCENT_COMPLETE]).isEqualTo(100)
val unset = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
assertThat(unset[Tasks.PERCENT_COMPLETE]).isNull()
}
@Test
fun `progress keeps completion in sync both ways`() {
// 100% completes the task (timestamp left to the provider / existing value).
val done = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 100),
tzId = "UTC",
)
assertThat(done[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED)
assertThat(done.containsKey(Tasks.COMPLETED)).isFalse()
// Below 100% reopens it and clears the completion timestamp.
val reopened = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 75),
tzId = "UTC",
)
assertThat(reopened[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_IN_PROCESS)
assertThat(reopened[Tasks.COMPLETED]).isNull()
// No percent in the form ⇒ status is left to the complete toggle.
val untouched = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
assertThat(untouched.containsKey(Tasks.STATUS)).isFalse()
}
@Test
fun `parent id is written so a task can be filed under another`() {
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, parentId = 7L),
tzId = "UTC",
)
assertThat(values[Tasks.PARENT_ID]).isEqualTo(7L)
}
@Test @Test
fun `all-day task clears the timezone`() { fun `all-day task clears the timezone`() {
val form = TaskForm( val form = TaskForm(

View File

@@ -35,4 +35,13 @@ class TaskFormTest {
val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate() val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate()
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE) assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
} }
@Test
fun `progress and parent auto-reveal only when they carry a value`() {
assertThat(TaskForm(title = "x", listId = 1).populatedFields()).isEmpty()
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 0, parentId = 0).populatedFields())
.isEmpty()
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
.containsExactly(TaskFormField.Progress, TaskFormField.Parent)
}
} }

View File

@@ -0,0 +1,47 @@
package de.jeanlucmakiola.floret.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class TaskSectionsTest {
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 sectionOf(task: Task) = TaskSections.sectionOf(task, todayStart, todayEnd)
@Test
fun `buckets by due relative to today`() {
assertThat(sectionOf(testTask(due = at(1000 * dayMs - 1)))).isEqualTo(TaskSection.OVERDUE)
assertThat(sectionOf(testTask(due = at(1000 * dayMs + 1)))).isEqualTo(TaskSection.TODAY)
assertThat(sectionOf(testTask(due = at(1001 * dayMs + 1)))).isEqualTo(TaskSection.UPCOMING)
assertThat(sectionOf(testTask(due = null))).isEqualTo(TaskSection.NO_DATE)
}
@Test
fun `completed always lands in completed regardless of due`() {
val overdueButDone = testTask(status = TaskStatus.COMPLETED, due = at(1000 * dayMs - 1))
assertThat(sectionOf(overdueButDone)).isEqualTo(TaskSection.COMPLETED)
}
@Test
fun `of returns only non-empty sections in fixed order`() {
val tasks = listOf(
testTask(id = 1, due = at(1001 * dayMs + 1)), // upcoming
testTask(id = 2, due = at(1000 * dayMs - 1)), // overdue
testTask(id = 3, status = TaskStatus.COMPLETED), // completed
)
val sections = TaskSections.of(tasks, todayStart, todayEnd)
assertThat(sections.map { it.section })
.containsExactly(TaskSection.OVERDUE, TaskSection.UPCOMING, TaskSection.COMPLETED)
.inOrder()
}
@Test
fun `empty input yields no sections`() {
assertThat(TaskSections.of(emptyList(), todayStart, todayEnd)).isEmpty()
}
}

View File

@@ -11,9 +11,12 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
## Current state (one line) ## Current state (one line)
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract` The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**; the Material 3 Expressive UI is being provider is **done and unit-tested**, and the Material 3 Expressive UI is built
built screen by screen on top of it. App builds, gates on provider/permission, through **M4**: lists → task list (swipe gestures, inline add, smart-list section
and renders the lists overview. headers) → detail / edit with full CRUD, date-time pickers, priority,
percent-complete, conflict-safe saves, per-task reminders, and subtask
create + reparent. Remaining work is M5 (notification / exact-alarm onboarding,
default reminder UI) and M6 (Settings screen, Glance widget, i18n, release).
--- ---
@@ -39,36 +42,81 @@ The complete non-visual stack:
- JVM unit tests across mappers, filtering, sorting, form, value mapping, and - JVM unit tests across mappers, filtering, sorting, form, value mapping, and
day windows. day windows.
### 🚧 M2 — Screens: lists, task list, complete & CRUD ### M2 — Screens: lists, task list, complete & CRUD
Replace the functional scaffold with the real Material 3 Expressive UI, screen The real Material 3 Expressive UI, built screen by screen against the M1
by screen, against the already-built ViewModels. ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`). - ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account. - ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- 🚧 Navigation host wiring (the `onOpenFilter` / `onNewTask` callbacks in - Navigation host wiring (`FloretNavHost` + `Dest` route table: lists → task
`RootScreen` are currently stubs). list → detail / edit, each binding its M1 ViewModel from route args).
- Task list screen — checkbox rows, swipe-to-complete / swipe-to-delete, - Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
inline add field, section headers for smart lists, FAB. -delete (`SwipeToDismissBox`), inline add field (`InlineAdd``quickAdd`),
- ⬜ Toggle complete (swipe + checkbox), create / edit / delete, local-list smart-list section headers (`TaskSections`).
creation wired to the UI. - ✅ Detail + edit screens — detail shows title / description / subtasks with
edit+delete; edit has the full CRUD form (title, description, save) wired to a
list picker.
### M3 — Detail / edit polish ### M3 — Detail / edit polish
Task detail and edit screens: due/start date-time pickers, priority, - ✅ Due / start date-time pickers (`DateTimePickerFlow` in `TaskEditScreen`).
percent-complete, conflict-safe saves (re-check before overwrite), smart-list - ✅ Priority — coloured by level: green / amber / red pastels (`priorityFill`;
section presentation. M3 has no priority role, only `error`). A shared `ui/common/PriorityChip` on the
list and detail screens, and the edit form's M3 segmented selector tints its
active segment in the chosen level's hue.
- ✅ Smart-list section presentation (`TaskSections` headers on the task list).
- ✅ Percent-complete field — optional "Progress" slider (5% detents) on the edit
form; writes `Tasks.PERCENT_COMPLETE` (clamped 0100, status left to the
complete toggle).
- ✅ Conflict-safe saves — `updateTask` re-checks `last_modified` against the
value captured when the form loaded and throws `TaskConflictException`; the
editor offers overwrite-or-cancel instead of clobbering an external change.
### M4 — Subtasks (UI) ### M4 — Subtasks (UI)
`RELATED-TO` hierarchy in the UI: indentation, create subtask, reparent. The `RELATED-TO` hierarchy in the UI. The data layer already reads `parentId`.
data layer already reads `parentId`; this is the trickiest UI piece. - ✅ Create subtask (`AddSubtaskField``TaskDetailViewModel.addSubtask` sets
`parentId`); subtasks render as a grouped section in the detail screen. Tapping
a subtask opens its own detail (a new `TaskDetail` entry, so it can have
children too), and the subtask's detail shows a tappable "Part of …" parent
card so it never reads as a stray standalone task.
- ✅ Inline expansion on the task list — a parent row has a dedicated expand
button (trailing chevron, separate from the count chip). Each section flattens
into one grouped run (parents + their expanded children) and corners are chosen
per-edge (`cornerPosition`): top-level tasks keep their own run, an expanded
parent opens its bottom to its children, and the child group rounds off on its
last segment while the next top-level task stays mid-run. Children are
full-width, set a step down in tone (`surfaceContainer` vs the parents'
`surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
row (always on for now; an opt-out toggle lands with the M6 settings screen).
Offered only where the list holds all the children (a real list; smart lists
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
parent for the detail card.
- ✅ Reparent — a full-width, searchable "Parent task" sheet on the edit form
groups active candidates by due-date section (Overdue / Today / Upcoming / No
date) and files a task under any top-level task in its list (or "None" to
promote it); switching list clears the now-invalid parent. Candidates stay
active + top-level to keep nesting one level deep, matching the detail screen.
### M5 — Reminders onboarding & polish ### 🚧 M5 — Reminders onboarding & polish
The engine exists (M1). Remaining: the `POST_NOTIFICATIONS` + exact-alarm The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
onboarding flow, per-task / default reminder-offset settings UI, and `DueReminderReceiver`, `TaskNotifier`).
end-to-end verification on device. - ✅ Per-task reminder-offset UI (`ReminderPickerDialog``TaskForm`
`reminderMinutesBeforeDue`).
-`POST_NOTIFICATIONS` + exact-alarm onboarding flow — currently only graceful
runtime checks (`TaskNotifier.canPost`, `canScheduleExactAlarms`), no
user-facing gate.
- ⬜ Default reminder-offset settings UI — `SettingsPrefs`/`SettingsViewModel`
back it, but no Composable exposes it.
- ⬜ End-to-end verification on device.
### M6 — Settings, widget, i18n, release ### 🚧 M6 — Settings, widget, i18n, release
Settings screen (theme / dynamic-color / language, default list, default - ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
reminder), Glance task-list widget, translations, finalize F-Droid metadata, - ⬜ Settings screen — `SettingsViewModel` exists but no `SettingsScreen`
confirm CI release flow. Composable and it is not in the nav graph (only used by `MainActivity` for
app-level theming). Needs theme / dynamic-color / language, default list,
default reminder, and the opt-out toggle for the task list's inline
add-a-subtask row (on by default since M4).
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
### ⬜ Posture B (separate track, later) ### ⬜ Posture B (separate track, later)
Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`; Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`;

View File

@@ -22,6 +22,7 @@ kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2" kotlinxCoroutines = "1.10.2"
turbine = "1.2.0" turbine = "1.2.0"
hiltNavigationCompose = "1.3.0" hiltNavigationCompose = "1.3.0"
navigationCompose = "2.9.0"
lifecycleCompose = "2.10.0" lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
@@ -78,6 +79,9 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine
# Hilt navigation-compose (for hiltViewModel() in Composables) # Hilt navigation-compose (for hiltViewModel() in Composables)
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Navigation-compose (the NavHost / back stack)
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
# Lifecycle compose (for collectAsStateWithLifecycle) # Lifecycle compose (for collectAsStateWithLifecycle)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }