Removed order from tasks

Tasks are ordered by priority and display is a bit different for each
Done tasks are shown at the bottom
Add done checkbox
Add priority segmented button to bottom sheet
Use database converters instead of manual mapping
Some refactoring
This commit is contained in:
Geoffroy Bonneville
2025-09-11 22:40:40 -04:00
parent d469fbc2ba
commit ef73319613
15 changed files with 270 additions and 92 deletions

View File

@@ -1,9 +1,9 @@
package com.wismna.geoffroy.donext.data
import androidx.room.TypeConverter
import com.wismna.geoffroy.donext.domain.model.Priority
import java.time.Instant
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Instant? {
@@ -14,4 +14,10 @@ class Converters {
fun instantToTimestamp(instant: Instant?): Long? {
return instant?.toEpochMilli()
}
@TypeConverter
fun fromPriority(priority: Priority): Int = priority.value
@TypeConverter
fun toPriority(value: Int): Priority = Priority.fromValue(value)
}

View File

@@ -4,19 +4,16 @@ import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList
import java.time.Instant
fun TaskEntity.toDomain() = Task(
id = id,
name = name,
taskListId = taskListId,
description = description,
cycle = cycle,
isDone = isDone,
isDeleted = isDeleted,
dueDate = if (dueDate == null) null else Instant.ofEpochMilli(dueDate),
dueDate = dueDate,
priority = priority,
order = order
)
fun Task.toEntity() = TaskEntity(
@@ -24,12 +21,10 @@ fun Task.toEntity() = TaskEntity(
name = name,
taskListId = taskListId,
description = description,
cycle = cycle,
priority = priority,
order = order,
isDone = isDone,
isDeleted = isDeleted,
dueDate = dueDate?.toEpochMilli()
dueDate = dueDate
)
fun TaskListEntity.toDomain() = TaskList(

View File

@@ -3,6 +3,8 @@ package com.wismna.geoffroy.donext.data.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.wismna.geoffroy.donext.domain.model.Priority
import java.time.Instant
@Entity(tableName = "tasks")
data class TaskEntity(
@@ -10,10 +12,7 @@ data class TaskEntity(
val id: Long = 0,
val name: String,
val description: String?,
val cycle: Int = 0,
val priority: Int,
@ColumnInfo(name = "display_order")
val order: Int,
val priority: Priority,
@ColumnInfo(name = "done")
val isDone: Boolean = false,
@ColumnInfo(name = "deleted")
@@ -21,5 +20,5 @@ data class TaskEntity(
@ColumnInfo(name = "task_list_id")
val taskListId: Long,
@ColumnInfo(name = "due_date")
val dueDate: Long? = null
val dueDate: Instant? = null
)

View File

@@ -41,9 +41,7 @@ abstract class AppDatabase : RoomDatabase() {
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
description TEXT,
cycle INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL,
display_order INTEGER NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
task_list_id INTEGER NOT NULL,
@@ -57,16 +55,14 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL(
"""
INSERT INTO tasks_new (
id, name, description, cycle, priority, display_order,
id, name, description, priority,
done, deleted, task_list_id, due_date
)
SELECT
_id, -- old '_id' mapped to id
name,
description,
cycle,
priority,
displayorder, -- old 'displayorder' mapped to display_order
done,
deleted,
list, -- old 'list' mapped to task_list_id

View File

@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks WHERE task_list_id = :listId ORDER BY display_order ASC")
@Query("SELECT * FROM tasks WHERE task_list_id = :listId ORDER BY done ASC, priority DESC")
fun getTasksForList(listId: Long): Flow<List<TaskEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -25,9 +25,6 @@ interface TaskDao {
@Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskId")
suspend fun markTaskDeleted(taskId: Long, deleted: Boolean)
@Query("UPDATE tasks SET cycle = cycle + 1 WHERE id = :taskId")
suspend fun increaseCycle(taskId: Long)
@Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskListId")
suspend fun deleteAllTasksFromList(taskListId: Long, deleted: Boolean)
}

View File

@@ -31,14 +31,10 @@ class TaskRepositoryImpl @Inject constructor(
taskDao.markTaskDeleted(taskId, isDeleted)
}
override suspend fun closeTask(taskId: Long, isDone: Boolean) {
override suspend fun toggleTaskDone(taskId: Long, isDone: Boolean) {
taskDao.markTaskDone(taskId, isDone)
}
override suspend fun increaseTaskCycle(taskId: Long) {
taskDao.increaseCycle(taskId)
}
override fun getTaskLists(): Flow<List<TaskList>> {
return taskListDao.getTaskLists().map {entities -> entities.map { it.toDomain() }}
}

View File

@@ -0,0 +1,12 @@
package com.wismna.geoffroy.donext.domain.model
enum class Priority(val value: Int, val label: String) {
LOW(0, "Low"),
NORMAL(1, "Normal"),
HIGH(2, "High");
companion object {
fun fromValue(value: Int): Priority =
Priority.entries.firstOrNull { it.value == value } ?: NORMAL
}
}

View File

@@ -6,9 +6,7 @@ data class Task(
val id: Long? = null,
val name: String,
val description: String?,
val cycle: Int,
val priority: Int,
val order: Int,
val priority: Priority,
val isDone: Boolean,
val isDeleted: Boolean,
val taskListId: Long,

View File

@@ -9,8 +9,7 @@ interface TaskRepository {
suspend fun insertTask(task: Task)
suspend fun updateTask(task: Task)
suspend fun deleteTask(taskId: Long, isDeleted: Boolean)
suspend fun closeTask(taskId: Long, isDone: Boolean)
suspend fun increaseTaskCycle(taskId: Long)
suspend fun toggleTaskDone(taskId: Long, isDone: Boolean)
fun getTaskLists(): Flow<List<TaskList>>
suspend fun insertTaskList(taskList: TaskList)

View File

@@ -1,5 +1,6 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
@@ -7,17 +8,15 @@ import javax.inject.Inject
class AddTaskUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(taskListId: Long, title: String, description: String?) {
suspend operator fun invoke(taskListId: Long, title: String, description: String?, priority: Priority) {
repository.insertTask(
Task(
taskListId = taskListId,
name = title,
description = description ?: "",
isDeleted = false,
cycle = 0,
isDone = false,
priority = 0,
order = 0
priority = priority,
)
)
}

View File

@@ -0,0 +1,12 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class ToggleTaskDoneUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(taskId: Long, isDone: Boolean) {
repository.toggleTaskDone(taskId, isDone)
}
}

View File

@@ -1,3 +1,5 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Box
@@ -19,9 +21,13 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -30,6 +36,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -39,11 +47,11 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
viewModel: MainViewModel,
@@ -62,39 +70,7 @@ fun MainScreen(
var selectedDestination by rememberSaveable { mutableIntStateOf(0) }
if (showBottomSheet) {
ModalBottomSheet(onDismissRequest = { showBottomSheet = false }) {
Column(Modifier.padding(16.dp)) {
Text("New Task", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.title,
singleLine = true,
onValueChange = { viewModel.onTitleChanged(it) },
label = { Text("Title") },
modifier = Modifier.fillMaxWidth(),
isError = !viewModel.isTitleValid && viewModel.title.isNotEmpty(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.description,
onValueChange = { viewModel.onDescriptionChanged(it) },
label = { Text("Description") },
maxLines = 3,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
val currentListId = viewModel.taskLists[selectedDestination].id
viewModel.createTask(currentListId)
showBottomSheet = false
},
modifier = Modifier.align(Alignment.End)
) {
Text("Add")
}
}
}
AddTaskBottomSheet(viewModel, selectedDestination, { showBottomSheet = false })
}
Scaffold(
@@ -123,7 +99,6 @@ fun MainScreen(
}
}
}) { contentPadding ->
// NavHost will now automatically be below the tabs
Box(modifier = Modifier
.padding(contentPadding)
.fillMaxSize()
@@ -155,11 +130,100 @@ fun AppNavHost(
}
}
@Composable
fun AddTaskBottomSheet(
viewModel: MainViewModel,
selectedListIndex: Int,
onDismiss: () -> Unit
) {
val titleFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
titleFocusRequester.requestFocus()
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.padding(16.dp)) {
Text(
"New Task",
style = MaterialTheme.typography.titleLarge
)
Spacer(Modifier.height(8.dp))
// --- Title ---
OutlinedTextField(
value = viewModel.title,
singleLine = true,
onValueChange = { viewModel.onTitleChanged(it) },
label = { Text("Title") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester),
isError = !viewModel.isTitleValid && viewModel.title.isNotEmpty(),
)
Spacer(Modifier.height(8.dp))
// --- Description ---
OutlinedTextField(
value = viewModel.description,
onValueChange = { viewModel.onDescriptionChanged(it) },
label = { Text("Description") },
maxLines = 3,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
// --- Priority ---
Text("Priority", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(4.dp))
SingleChoiceSegmentedButton(
value = viewModel.priority,
onValueChange = { viewModel.onPriorityChanged(it) }
)
Spacer(Modifier.height(16.dp))
// --- Add Button ---
Button(
onClick = {
val currentListId = viewModel.taskLists[selectedListIndex].id
viewModel.createTask(currentListId)
onDismiss()
viewModel.resetTaskForm()
},
modifier = Modifier.align(Alignment.End)
) {
Text("Add")
}
}
}
}
@Composable
fun AddNewTaskButton(onClick: () -> Unit) {
ExtendedFloatingActionButton(
onClick = { onClick() },
onClick = onClick,
icon = { Icon(Icons.Filled.Add, "Create a task.") },
text = { Text(text = "Create a task") },
)
}
@Composable
fun SingleChoiceSegmentedButton(
value: Priority,
onValueChange: (Priority) -> Unit) {
val options = listOf(Priority.LOW.label, Priority.NORMAL.label, Priority.HIGH.label)
SingleChoiceSegmentedButtonRow {
options.forEachIndexed { index, label ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size
),
onClick = { onValueChange(Priority.fromValue(index)) },
selected = index == value.value,
label = { Text(label) }
)
}
}
}

View File

@@ -1,21 +1,31 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
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.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
@Composable
@@ -23,21 +33,96 @@ fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) {
val tasks = viewModel.tasks
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(tasks) { task ->
modifier = Modifier.fillMaxSize()
) {
itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task ->
if (index > 0) {
val prev = tasks[index - 1]
when {
// Divider between non-done and done tasks
!prev.isDone && task.isDone -> {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
modifier = Modifier.padding(vertical = 8.dp)
)
}
// Extra spacing between different priorities (only if done status is same)
prev.priority != task.priority && prev.isDone == task.isDone -> {
Spacer(modifier = Modifier.height(20.dp))
}
}
}
TaskItem(
task = task,
onToggleDone = { isChecked ->
viewModel.updateTaskDone(task.id!!, isChecked)
}
)
}
}
}
@Composable
fun TaskItem(
task: Task,
onToggleDone: (Boolean) -> Unit
) {
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (task.priority) {
Priority.HIGH -> FontWeight.Bold
Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> FontWeight.Normal
},
color = when (task.priority) {
Priority.HIGH -> MaterialTheme.colorScheme.onSurface
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
},
textDecoration = if (task.isDone) TextDecoration.LineThrough else TextDecoration.None)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.alpha(if (task.isDone) 0.5f else 1f),
) {
Column {
}
Column {
val textStyle = TextStyle(textDecoration = if (task.isDeleted) TextDecoration.LineThrough else TextDecoration.None)
Text(task.name, fontSize = 18.sp, color = Color.DarkGray, style = textStyle)
Text(task.description.orEmpty(), fontSize = 14.sp, color = Color.LightGray, style = textStyle, maxLines = 3)
}
/*Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {*/
Checkbox(
checked = task.isDone,
onCheckedChange = onToggleDone,
modifier = Modifier
.size(40.dp) // Adjust size as needed
.clip(CircleShape)
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = task.name,
style = baseStyle
)
if (!task.description.isNullOrBlank()) {
Text(
text = task.description,
style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize
),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
// }
}
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
@@ -30,6 +31,8 @@ class MainViewModel @Inject constructor(
private set
var description by mutableStateOf("")
private set
var priority by mutableStateOf(Priority.NORMAL)
private set
val isTitleValid: Boolean
get() = title.isNotBlank()
@@ -46,15 +49,23 @@ class MainViewModel @Inject constructor(
fun createTask(taskListId: Long) {
if (!isTitleValid) return
viewModelScope.launch {
addTask(taskListId, title, description)
addTask(taskListId, title, description, priority)
}
}
fun onTitleChanged(newTitle: String) {
title = newTitle
}
fun onDescriptionChanged(newDesc: String) {
description = newDesc
}
fun onPriorityChanged(newPriority: Priority) {
priority = newPriority
}
fun resetTaskForm() {
title = ""
description = ""
priority = Priority.NORMAL
}
}

View File

@@ -7,15 +7,18 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@@ -35,4 +38,10 @@ class TaskListViewModel @Inject constructor(
}
.launchIn(viewModelScope)
}
fun updateTaskDone(taskId: Long, isDone: Boolean) {
viewModelScope.launch {
toggleTaskDone(taskId, isDone)
}
}
}