Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c457f3915 | |||
| e0c56a73e2 | |||
| 3db553da85 | |||
| 11f9649dd7 | |||
| 3397e57794 | |||
| 24cf8fe331 | |||
| a19b1373a4 | |||
| b196d9ebe0 |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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) ------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */ },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()}"
|
||||||
@@ -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() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1010
app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt
Normal file
1010
app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||||
|
TaskEditUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
isNew = true,
|
isNew = true,
|
||||||
listId = defaultList,
|
listId = defaultList,
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
lists = lists,
|
lists = lists,
|
||||||
|
parentCandidates = loadParents(defaultList, selfId = null),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,13 +105,16 @@ 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
|
||||||
|
_state.value = withFields(
|
||||||
|
TaskEditUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
isNew = false,
|
isNew = false,
|
||||||
title = task.title,
|
title = task.title,
|
||||||
@@ -93,33 +125,72 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
isAllDay = task.isAllDay,
|
isAllDay = task.isAllDay,
|
||||||
priority = task.priority,
|
priority = task.priority,
|
||||||
parentId = task.parentId,
|
parentId = task.parentId,
|
||||||
|
percentComplete = task.percentComplete,
|
||||||
lists = lists,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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) ->
|
||||||
|
|||||||
@@ -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("&")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
102
docs/ROADMAP.md
102
docs/ROADMAP.md
@@ -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 0–100, 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`;
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user