Added overdue task counter on task lists in drawer

Display due date in badge on the right of task
Added a unit test
Limit due dates to dates in the future
Fix due date migration
WIP Edit lists screen
WIP Refactor navhost with viewmodel
This commit is contained in:
Geoffroy Bonneville
2025-09-15 20:40:24 -04:00
parent 744d2afdc1
commit 1692a197f2
19 changed files with 402 additions and 126 deletions

View File

@@ -0,0 +1,90 @@
package com.wismna.geoffroy.donext.data.local.repository
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.AppDatabase
import com.wismna.geoffroy.donext.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.domain.model.Priority
import junit.framework.TestCase
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {
private lateinit var db: AppDatabase
private lateinit var taskDao: TaskDao
private lateinit var listDao: TaskListDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).allowMainThreadQueries().build()
taskDao = db.taskDao()
listDao = db.taskListDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun overdueCount_correctlyCalculated() = runBlocking {
listDao.insertTaskList(TaskListEntity(name = "Work", order = 0))
val listId = listDao.getTaskLists().first().first().id
val now = Instant.parse("2025-09-15T12:00:00Z").toEpochMilli()
// One overdue task (yesterday)
taskDao.insertTask(
TaskEntity(
name = "Finish report",
taskListId = listId,
dueDate = Instant.parse("2025-09-14T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One not overdue task (tomorrow)
taskDao.insertTask(
TaskEntity(
name = "Prepare slides",
taskListId = listId,
dueDate = Instant.parse("2025-09-16T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One done task (yesterday, but marked done)
taskDao.insertTask(
TaskEntity(
name = "Old task",
taskListId = listId,
dueDate = Instant.parse("2025-09-14T12:00:00Z").toEpochMilli(),
isDone = true,
description = null,
priority = Priority.NORMAL
)
)
val lists = listDao.getTaskListsWithOverdue(now)
TestCase.assertEquals(1, lists.first().first().overdueCount)
}
}

View File

@@ -8,10 +8,10 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.wismna.geoffroy.donext.data.Converters
import com.wismna.geoffroy.donext.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -34,41 +34,44 @@ abstract class AppDatabase : RoomDatabase() {
db.beginTransaction()
try {
// --- TASKS TABLE ---
// 0. Convert old due date format
// Add temporary column
db.execSQL("ALTER TABLE tasks ADD COLUMN duedate_temp INTEGER")
// Populate temporary column
db.execSQL("""
UPDATE tasks
SET duedate_temp =
CASE
WHEN duedate IS NULL OR duedate = '' THEN NULL
ELSE (strftime('%s', duedate || 'T00:00:00Z') * 1000)
END
""".trimIndent())
// 1. Create the new tasks table with the updated schema
db.execSQL(
"""
CREATE TABLE tasks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
description TEXT,
priority INTEGER NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
task_list_id INTEGER NOT NULL,
due_date INTEGER
)
""".trimIndent()
CREATE TABLE tasks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
description TEXT,
priority INTEGER NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
task_list_id INTEGER NOT NULL,
due_date INTEGER
)
""".trimIndent()
)
// 2. Copy old data into the new table
// Map old column names to new ones
db.execSQL(
"""
INSERT INTO tasks_new (
id, name, description, priority,
done, deleted, task_list_id, due_date
)
SELECT
_id, -- old '_id' mapped to id
name,
description,
priority,
done,
deleted,
list, -- old 'list' mapped to task_list_id
duedate -- old column renamed to due_date
FROM tasks
""".trimIndent()
INSERT INTO tasks_new (id, name, description, priority,done, deleted, task_list_id, due_date)
SELECT _id, name, description, priority, done, deleted, list, duedate_temp
FROM tasks
""".trimIndent()
)
// 3. Drop the old table
@@ -80,27 +83,21 @@ abstract class AppDatabase : RoomDatabase() {
// --- TASK_LISTS TABLE ---
db.execSQL(
"""
CREATE TABLE task_lists_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL
)
""".trimIndent()
CREATE TABLE task_lists_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO task_lists_new (
id, name, display_order, deleted
)
SELECT
_id, -- old '_id' mapped to id
name,
displayorder, -- old 'displayorder' mapped to display_order
1 - visible -- old 'visible' mapped to deleted
FROM tasklist
""".trimIndent()
INSERT INTO task_lists_new (id, name, display_order, deleted)
SELECT _id, name, displayorder, 1 - visible
FROM tasklist
""".trimIndent()
)
db.execSQL("DROP TABLE tasklist")

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 done ASC, priority DESC")
@Query("SELECT * FROM tasks WHERE task_list_id = :listId AND deleted = 0 ORDER BY done ASC, priority DESC")
fun getTasksForList(listId: Long): Flow<List<TaskEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@@ -15,14 +15,14 @@ interface TaskListDao {
fun getTaskLists(): Flow<List<TaskListEntity>>
@Query("""
SELECT
SELECT
tl.id AS id,
tl.name AS name,
COALESCE(SUM(
CASE
WHEN t.done = 0
AND t.due_date IS NOT NULL
AND t.due_date < :today
AND t.due_date IS NOT NULL
AND t.due_date < :nowMillis
THEN 1
ELSE 0
END
@@ -31,7 +31,7 @@ interface TaskListDao {
LEFT JOIN tasks t ON t.task_list_id = tl.id
GROUP BY tl.id
""")
fun getTaskListsWithOverdue(today: Long): Flow<List<TaskListWithOverdue>>
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTaskList(taskList: TaskListEntity)

View File

@@ -10,8 +10,6 @@ import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject
class TaskRepositoryImpl @Inject constructor(
@@ -51,13 +49,7 @@ class TaskRepositoryImpl @Inject constructor(
taskListDao.deleteTaskList(taskListId, isDeleted)
}
override fun getTaskListsWithOverdue(): Flow<List<TaskListWithOverdue>> {
val todayMillis = LocalDate.now()
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
return taskListDao.getTaskListsWithOverdue(todayMillis)
override fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> {
return taskListDao.getTaskListsWithOverdue(nowMillis).map { it }
}
}

View File

@@ -15,5 +15,5 @@ interface TaskRepository {
fun getTaskLists(): Flow<List<TaskList>>
suspend fun insertTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
fun getTaskListsWithOverdue(): Flow<List<TaskListWithOverdue>>
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
}

View File

@@ -3,12 +3,13 @@ package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import java.time.Instant
import javax.inject.Inject
class GetTaskListsWithOverdueUseCase @Inject constructor(
private val taskRepository: TaskRepository
) {
operator fun invoke(): Flow<List<TaskListWithOverdue>> {
return taskRepository.getTaskListsWithOverdue()
return taskRepository.getTaskListsWithOverdue(Instant.parse("2025-09-15T12:00:00Z").toEpochMilli())
}
}

View File

@@ -8,15 +8,15 @@ import javax.inject.Inject
class UpdateTaskUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(taskId: Long, taskListId: Long, title: String, description: String?, priority: Priority, dueDate: Long?) {
suspend operator fun invoke(taskId: Long, taskListId: Long, title: String, description: String?, priority: Priority, dueDate: Long?, isDone: Boolean) {
repository.updateTask(
Task(
id = taskId,
taskListId = taskListId,
name = title,
description = description ?: "",
description = description,
isDeleted = false,
isDone = false,
isDone = isDone,
priority = priority,
dueDate = dueDate
)

View File

@@ -4,14 +4,8 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.presentation.screen.MainScreen
import com.wismna.geoffroy.donext.presentation.ui.theme.DoNextTheme
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -20,15 +14,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DoNextTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val viewModel: MainViewModel = hiltViewModel<MainViewModel>()
MainScreen(
viewModel,
modifier = Modifier.padding(innerPadding)
)
}
}
DoNextTheme { MainScreen() }
}
}
}

View File

@@ -7,12 +7,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Badge
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -42,6 +46,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.domain.model.Priority
@@ -51,8 +56,8 @@ import kotlinx.coroutines.launch
@Composable
fun MainScreen(
viewModel: MainViewModel,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier,
viewModel: MainViewModel = hiltViewModel<MainViewModel>()) {
val navController = rememberNavController()
var showBottomSheet by remember { mutableStateOf(false) }
@@ -68,6 +73,9 @@ fun MainScreen(
var selectedDestination by rememberSaveable { mutableIntStateOf(0) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination?.route
val isManageLists = currentDestination == "manageLists"
if (showBottomSheet) {
TaskBottomSheet(taskViewModel, { showBottomSheet = false })
@@ -80,24 +88,42 @@ fun MainScreen(
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
viewModel.taskLists.forEachIndexed { index, list ->
viewModel.taskLists.forEachIndexed { index, destination ->
NavigationDrawerItem(
label = { Text(list.name) },
label = { Text(destination.name) },
icon = {
Icon(
imageVector = Icons.Default.List,
contentDescription = destination.name
)},
selected = selectedDestination == index,
onClick = {
selectedDestination = index
scope.launch { drawerState.close() }
navController.navigate(route = "taskList/${destination.id}")
selectedDestination = index
},
badge = {
if (list.overdueCount > 0) {
if (destination.overdueCount > 0) {
Badge {
Text(list.overdueCount.toString())
Text(destination.overdueCount.toString())
}
}
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
HorizontalDivider(modifier = Modifier)
NavigationDrawerItem(
label = { Text("Edit Lists") },
icon = { Icon(Icons.Default.Edit, contentDescription = "Edit Lists") },
selected = false,
onClick = {
scope.launch { drawerState.close() }
navController.navigate("manageLists")
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
},
drawerState = drawerState
@@ -105,19 +131,30 @@ fun MainScreen(
Scaffold(
modifier = modifier,
floatingActionButton = {
AddNewTaskButton {
val currentListId = viewModel.taskLists[selectedDestination].id
taskViewModel.startNewTask(currentListId)
showBottomSheet = true
if (!isManageLists) {
AddNewTaskButton {
val currentListId = viewModel.taskLists[selectedDestination].id
taskViewModel.startNewTask(currentListId)
showBottomSheet = true
}
}
}, topBar = {
// TODO: add list title
// TODO: add button such as edit and delete
TopAppBar(
title = { Text(viewModel.taskLists.getOrNull(selectedDestination)?.name ?: "Tasks") },
title = {
Text(
if (isManageLists) "Manage Lists"
else viewModel.taskLists.getOrNull(selectedDestination)?.name ?: "Tasks"
)
},
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Default.Menu, contentDescription = "Open navigation drawer")
if (isManageLists) {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
} else {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Default.Menu, contentDescription = "Open navigation drawer")
}
}
}
)
@@ -141,6 +178,12 @@ fun MainScreen(
})
}
}
composable("manageLists") {
ManageListsScreen(
modifier = Modifier.padding(contentPadding),
onBackClick = { navController.popBackStack() }
)
}
}
}
}

View File

@@ -0,0 +1,49 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ManageListsScreen(
modifier: Modifier,
viewModel: MainViewModel = hiltViewModel(),
onBackClick: () -> Unit
) {
val lists = viewModel.taskLists
LazyColumn(modifier = modifier.fillMaxWidth().padding()) {
itemsIndexed(lists, key = { _, list -> list.id }) { index, list ->
ListItem(
headlineContent = { Text(list.name) },
trailingContent = {
Row {
IconButton(onClick = { /* TODO: edit list */ }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
IconButton(onClick = { /* TODO: delete list */ }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
)
HorizontalDivider()
}
}
}

View File

@@ -1,9 +1,13 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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
@@ -11,6 +15,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -20,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
@@ -31,13 +37,17 @@ import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
@Composable
fun TaskListScreen(modifier: Modifier = Modifier, viewModel: TaskListViewModel = hiltViewModel(), onTaskClick: (Task) -> Unit) {
fun TaskListScreen(modifier: Modifier = Modifier, viewModel: TaskListViewModel = hiltViewModel<TaskListViewModel>(), onTaskClick: (Task) -> Unit) {
val tasks = viewModel.tasks
LazyColumn(
//modifier = Modifier.fillMaxSize()
modifier = modifier.fillMaxSize().padding()
) {
itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task ->
if (index > 0) {
@@ -93,13 +103,14 @@ fun TaskItem(
Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> FontWeight.Normal
},
color = if (isOverdue && !task.isDone) MaterialTheme.colorScheme.error else when (task.priority) {
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)
val dueText = task.dueDate?.let { formatTaskDueDate(it) }
Row(
modifier = Modifier
.fillMaxWidth()
@@ -107,33 +118,68 @@ fun TaskItem(
.padding(8.dp)
.alpha(if (task.isDone || task.priority == Priority.LOW) 0.5f else 1f),
) {
Checkbox(
checked = task.isDone,
onCheckedChange = onToggleDone,
modifier = Modifier
.size(40.dp) // Adjust size as needed
.clip(CircleShape)
)
Checkbox(
checked = task.isDone,
onCheckedChange = onToggleDone,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
)
Column(
modifier = Modifier.weight(1f)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween) {
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
)
// Due date badge
dueText?.let {
Box(
modifier = Modifier
.background(
color = if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary.copy(
alpha = 0.1f
),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = if (isOverdue) Color.White else MaterialTheme.colorScheme.primary
)
}
}
}
// }
if (!task.description.isNullOrBlank()) {
Text(
text = task.description,
style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize
),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
private fun formatTaskDueDate(dueMillis: Long): String {
val today = LocalDate.now()
val dueDate = Instant.ofEpochMilli(dueMillis).atZone(ZoneOffset.UTC).toLocalDate()
return when {
dueDate.isEqual(today) -> "Today"
dueDate.isEqual(today.plusDays(1)) -> "Tomorrow"
dueDate.isEqual(today.minusDays(1)) -> "Yesterday"
dueDate.isAfter(today) && dueDate.isBefore(today.plusDays(7)) ->
dueDate.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault())
else ->
dueDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()))
}
}

View File

@@ -20,6 +20,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
@@ -36,6 +37,8 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -64,6 +67,7 @@ fun TaskBottomSheet(
OutlinedTextField(
value = viewModel.title,
singleLine = true,
readOnly = viewModel.isDone,
onValueChange = { viewModel.onTitleChanged(it) },
label = { Text("Title") },
modifier = Modifier
@@ -76,6 +80,7 @@ fun TaskBottomSheet(
// --- Description ---
OutlinedTextField(
value = viewModel.description,
readOnly = viewModel.isDone,
onValueChange = { viewModel.onDescriptionChanged(it) },
label = { Text("Description") },
maxLines = 3,
@@ -113,11 +118,15 @@ fun TaskBottomSheet(
trailingIcon = {
Row {
if (viewModel.dueDate != null) {
IconButton(onClick = { viewModel.onDueDateChanged(null) }) {
IconButton(
onClick = { viewModel.onDueDateChanged(null) },
enabled = !viewModel.isDone) {
Icon(Icons.Default.Clear, contentDescription = "Clear due date")
}
}
IconButton(onClick = { showDatePicker = true }) {
IconButton(
onClick = { showDatePicker = true },
enabled = !viewModel.isDone) {
Icon(Icons.Default.DateRange, contentDescription = "Pick due date")
}
}
@@ -126,7 +135,18 @@ fun TaskBottomSheet(
)
if (showDatePicker) {
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = viewModel.dueDate)
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = viewModel.dueDate,
selectableDates = object: SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val todayStartMillis = LocalDate.now()
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
return utcTimeMillis >= todayStartMillis
}
}
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
@@ -164,8 +184,7 @@ fun TaskBottomSheet(
viewModel.save()
onDismiss()
},
enabled = viewModel.title.isNotBlank(),
//modifier = Modifier.align(Alignment.End)
enabled = viewModel.title.isNotBlank() && !viewModel.isDone,
) {
Text(if (viewModel.isEditing()) "Save" else "Create")
}

View File

@@ -12,6 +12,27 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
sealed class AppDestination(
val route: String,
val title: String,
val showFab: Boolean = false,
val showBackButton: Boolean = false
) {
data class TaskList(val taskListId: Long, val name: String) : AppDestination(
route = "taskList/$taskListId",
title = name,
showFab = true,
showBackButton = false
)
object ManageLists : AppDestination(
route = "manageLists",
title = "Manage Lists",
showFab = false,
showBackButton = true
)
}
@HiltViewModel
class MainViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase
@@ -19,6 +40,9 @@ class MainViewModel @Inject constructor(
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set
val destinations: List<AppDestination>
get() = taskLists.map { AppDestination.TaskList(it.id, it.name) } +
AppDestination.ManageLists
var isLoading by mutableStateOf(true)
private set

View File

@@ -29,6 +29,8 @@ class TaskViewModel @Inject constructor(
private set
var dueDate by mutableStateOf<Long?>(null)
private set
var isDone by mutableStateOf(false)
private set
private var editingTaskId: Long? = null
private var taskListId: Long? = null
@@ -51,6 +53,7 @@ class TaskViewModel @Inject constructor(
description = task.description ?: ""
priority = task.priority
dueDate = task.dueDate
isDone = task.isDone
}
fun onTitleChanged(value: String) { title = value }
@@ -63,7 +66,7 @@ class TaskViewModel @Inject constructor(
viewModelScope.launch {
if (isEditing()) {
updateTaskUseCase(editingTaskId!!, taskListId!!, title, description, priority, dueDate)
updateTaskUseCase(editingTaskId!!, taskListId!!, title, description, priority, dueDate, isDone)
} else {
createTaskUseCase(taskListId!!, title, description, priority, dueDate)
}