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,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="1337588336">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@@ -8,6 +8,9 @@
<SelectionState runConfigName="donextv2"> <SelectionState runConfigName="donextv2">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
</SelectionState> </SelectionState>
<SelectionState runConfigName="overdueCount_correctlyCalculated()">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates> </selectionStates>
</component> </component>
</project> </project>

View File

@@ -55,6 +55,7 @@ dependencies {
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:2.9.4") implementation("androidx.navigation:navigation-compose:2.9.4")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0") implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("androidx.test.ext:junit-ktx:1.3.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.00")) androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")

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.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.wismna.geoffroy.donext.data.Converters 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.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -34,6 +34,20 @@ abstract class AppDatabase : RoomDatabase() {
db.beginTransaction() db.beginTransaction()
try { try {
// --- TASKS TABLE --- // --- 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 // 1. Create the new tasks table with the updated schema
db.execSQL( db.execSQL(
""" """
@@ -54,19 +68,8 @@ abstract class AppDatabase : RoomDatabase() {
// Map old column names to new ones // Map old column names to new ones
db.execSQL( db.execSQL(
""" """
INSERT INTO tasks_new ( INSERT INTO tasks_new (id, name, description, priority,done, deleted, task_list_id, due_date)
id, name, description, priority, SELECT _id, name, description, priority, done, deleted, list, duedate_temp
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 FROM tasks
""".trimIndent() """.trimIndent()
) )
@@ -91,14 +94,8 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL( db.execSQL(
""" """
INSERT INTO task_lists_new ( INSERT INTO task_lists_new (id, name, display_order, deleted)
id, name, display_order, deleted SELECT _id, name, displayorder, 1 - visible
)
SELECT
_id, -- old '_id' mapped to id
name,
displayorder, -- old 'displayorder' mapped to display_order
1 - visible -- old 'visible' mapped to deleted
FROM tasklist FROM tasklist
""".trimIndent() """.trimIndent()
) )

View File

@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface TaskDao { 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>> fun getTasksForList(listId: Long): Flow<List<TaskEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@@ -22,7 +22,7 @@ interface TaskListDao {
CASE CASE
WHEN t.done = 0 WHEN t.done = 0
AND t.due_date IS NOT NULL AND t.due_date IS NOT NULL
AND t.due_date < :today AND t.due_date < :nowMillis
THEN 1 THEN 1
ELSE 0 ELSE 0
END END
@@ -31,7 +31,7 @@ interface TaskListDao {
LEFT JOIN tasks t ON t.task_list_id = tl.id LEFT JOIN tasks t ON t.task_list_id = tl.id
GROUP BY tl.id GROUP BY tl.id
""") """)
fun getTaskListsWithOverdue(today: Long): Flow<List<TaskListWithOverdue>> fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTaskList(taskList: TaskListEntity) 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 com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject import javax.inject.Inject
class TaskRepositoryImpl @Inject constructor( class TaskRepositoryImpl @Inject constructor(
@@ -51,13 +49,7 @@ class TaskRepositoryImpl @Inject constructor(
taskListDao.deleteTaskList(taskListId, isDeleted) taskListDao.deleteTaskList(taskListId, isDeleted)
} }
override fun getTaskListsWithOverdue(): Flow<List<TaskListWithOverdue>> { override fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> {
val todayMillis = LocalDate.now() return taskListDao.getTaskListsWithOverdue(nowMillis).map { it }
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
return taskListDao.getTaskListsWithOverdue(todayMillis)
} }
} }

View File

@@ -15,5 +15,5 @@ interface TaskRepository {
fun getTaskLists(): Flow<List<TaskList>> fun getTaskLists(): Flow<List<TaskList>>
suspend fun insertTaskList(taskList: TaskList) suspend fun insertTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) 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.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskRepository import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
class GetTaskListsWithOverdueUseCase @Inject constructor( class GetTaskListsWithOverdueUseCase @Inject constructor(
private val taskRepository: TaskRepository private val taskRepository: TaskRepository
) { ) {
operator fun invoke(): Flow<List<TaskListWithOverdue>> { 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( class UpdateTaskUseCase @Inject constructor(
private val repository: TaskRepository 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( repository.updateTask(
Task( Task(
id = taskId, id = taskId,
taskListId = taskListId, taskListId = taskListId,
name = title, name = title,
description = description ?: "", description = description,
isDeleted = false, isDeleted = false,
isDone = false, isDone = isDone,
priority = priority, priority = priority,
dueDate = dueDate dueDate = dueDate
) )

View File

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

View File

@@ -7,12 +7,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.material.icons.filled.Menu
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -42,6 +46,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Priority
@@ -51,8 +56,8 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel, modifier: Modifier = Modifier,
modifier: Modifier = Modifier) { viewModel: MainViewModel = hiltViewModel<MainViewModel>()) {
val navController = rememberNavController() val navController = rememberNavController()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
@@ -68,6 +73,9 @@ fun MainScreen(
var selectedDestination by rememberSaveable { mutableIntStateOf(0) } var selectedDestination by rememberSaveable { mutableIntStateOf(0) }
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination?.route
val isManageLists = currentDestination == "manageLists"
if (showBottomSheet) { if (showBottomSheet) {
TaskBottomSheet(taskViewModel, { showBottomSheet = false }) TaskBottomSheet(taskViewModel, { showBottomSheet = false })
@@ -80,24 +88,42 @@ fun MainScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
viewModel.taskLists.forEachIndexed { index, list -> viewModel.taskLists.forEachIndexed { index, destination ->
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(list.name) }, label = { Text(destination.name) },
icon = {
Icon(
imageVector = Icons.Default.List,
contentDescription = destination.name
)},
selected = selectedDestination == index, selected = selectedDestination == index,
onClick = { onClick = {
selectedDestination = index
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(route = "taskList/${destination.id}")
selectedDestination = index
}, },
badge = { badge = {
if (list.overdueCount > 0) { if (destination.overdueCount > 0) {
Badge { Badge {
Text(list.overdueCount.toString()) Text(destination.overdueCount.toString())
} }
} }
}, },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) 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 drawerState = drawerState
@@ -105,21 +131,32 @@ fun MainScreen(
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
floatingActionButton = { floatingActionButton = {
if (!isManageLists) {
AddNewTaskButton { AddNewTaskButton {
val currentListId = viewModel.taskLists[selectedDestination].id val currentListId = viewModel.taskLists[selectedDestination].id
taskViewModel.startNewTask(currentListId) taskViewModel.startNewTask(currentListId)
showBottomSheet = true showBottomSheet = true
} }
}
}, topBar = { }, topBar = {
// TODO: add list title
// TODO: add button such as edit and delete
TopAppBar( TopAppBar(
title = { Text(viewModel.taskLists.getOrNull(selectedDestination)?.name ?: "Tasks") }, title = {
Text(
if (isManageLists) "Manage Lists"
else viewModel.taskLists.getOrNull(selectedDestination)?.name ?: "Tasks"
)
},
navigationIcon = { navigationIcon = {
if (isManageLists) {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
} else {
IconButton(onClick = { scope.launch { drawerState.open() } }) { IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Default.Menu, contentDescription = "Open navigation drawer") Icon(Icons.Default.Menu, contentDescription = "Open navigation drawer")
} }
} }
}
) )
}) { contentPadding -> }) { contentPadding ->
NavHost( NavHost(
@@ -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 package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -20,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow 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.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
@Composable @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 val tasks = viewModel.tasks
LazyColumn( LazyColumn(
//modifier = Modifier.fillMaxSize() modifier = modifier.fillMaxSize().padding()
) { ) {
itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task -> itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task ->
if (index > 0) { if (index > 0) {
@@ -93,13 +103,14 @@ fun TaskItem(
Priority.NORMAL -> FontWeight.Normal Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> 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.HIGH -> MaterialTheme.colorScheme.onSurface
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
}, },
textDecoration = if (task.isDone) TextDecoration.LineThrough else TextDecoration.None) textDecoration = if (task.isDone) TextDecoration.LineThrough else TextDecoration.None)
val dueText = task.dueDate?.let { formatTaskDueDate(it) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -111,17 +122,38 @@ fun TaskItem(
checked = task.isDone, checked = task.isDone,
onCheckedChange = onToggleDone, onCheckedChange = onToggleDone,
modifier = Modifier modifier = Modifier
.size(40.dp) // Adjust size as needed .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
) )
Column( Column {
modifier = Modifier.weight(1f) Row(
) { modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween) {
Text( Text(
text = task.name, text = task.name,
style = baseStyle style = baseStyle
) )
// 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()) { if (!task.description.isNullOrBlank()) {
Text( Text(
@@ -134,6 +166,20 @@ fun TaskItem(
) )
} }
} }
// } }
}
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.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberDatePickerState
@@ -36,6 +37,8 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
@@ -64,6 +67,7 @@ fun TaskBottomSheet(
OutlinedTextField( OutlinedTextField(
value = viewModel.title, value = viewModel.title,
singleLine = true, singleLine = true,
readOnly = viewModel.isDone,
onValueChange = { viewModel.onTitleChanged(it) }, onValueChange = { viewModel.onTitleChanged(it) },
label = { Text("Title") }, label = { Text("Title") },
modifier = Modifier modifier = Modifier
@@ -76,6 +80,7 @@ fun TaskBottomSheet(
// --- Description --- // --- Description ---
OutlinedTextField( OutlinedTextField(
value = viewModel.description, value = viewModel.description,
readOnly = viewModel.isDone,
onValueChange = { viewModel.onDescriptionChanged(it) }, onValueChange = { viewModel.onDescriptionChanged(it) },
label = { Text("Description") }, label = { Text("Description") },
maxLines = 3, maxLines = 3,
@@ -113,11 +118,15 @@ fun TaskBottomSheet(
trailingIcon = { trailingIcon = {
Row { Row {
if (viewModel.dueDate != null) { 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") 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") Icon(Icons.Default.DateRange, contentDescription = "Pick due date")
} }
} }
@@ -126,7 +135,18 @@ fun TaskBottomSheet(
) )
if (showDatePicker) { 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( DatePickerDialog(
onDismissRequest = { showDatePicker = false }, onDismissRequest = { showDatePicker = false },
@@ -164,8 +184,7 @@ fun TaskBottomSheet(
viewModel.save() viewModel.save()
onDismiss() onDismiss()
}, },
enabled = viewModel.title.isNotBlank(), enabled = viewModel.title.isNotBlank() && !viewModel.isDone,
//modifier = Modifier.align(Alignment.End)
) { ) {
Text(if (viewModel.isEditing()) "Save" else "Create") Text(if (viewModel.isEditing()) "Save" else "Create")
} }

View File

@@ -12,6 +12,27 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import javax.inject.Inject 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 @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase
@@ -19,6 +40,9 @@ class MainViewModel @Inject constructor(
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList()) var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set private set
val destinations: List<AppDestination>
get() = taskLists.map { AppDestination.TaskList(it.id, it.name) } +
AppDestination.ManageLists
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
private set private set

View File

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