From 1692a197f2400da14d433931f8da129582dd1959 Mon Sep 17 00:00:00 2001
From: Geoffroy Bonneville <24917789+wismna@users.noreply.github.com>
Date: Mon, 15 Sep 2025 20:40:24 -0400
Subject: [PATCH] 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
---
.idea/androidTestResultsUserPreferences.xml | 22 +++++
.idea/deploymentTargetSelector.xml | 3 +
donextv2/build.gradle.kts | 1 +
.../data/local/repository/TaskDaoTest.kt | 90 ++++++++++++++++++
.../geoffroy/donext/data/local/Database.kt | 87 +++++++++--------
.../geoffroy/donext/data/local/dao/TaskDao.kt | 2 +-
.../donext/data/local/dao/TaskListDao.kt | 8 +-
.../local/repository/TaskRepositoryImpl.kt | 12 +--
...tWIthOverdue.kt => TaskListWithOverdue.kt} | 0
.../domain/repository/TaskRepository.kt | 2 +-
.../usecase/GetTaskListsWithOverdueUseCase.kt | 3 +-
.../domain/usecase/UpdateTaskUseCase.kt | 6 +-
.../donext/presentation/MainActivity.kt | 16 +---
.../donext/presentation/screen/MainScreen.kt | 75 +++++++++++----
.../presentation/screen/ManageListsScreen.kt | 49 ++++++++++
.../presentation/screen/TaskListScreen.kt | 94 ++++++++++++++-----
.../donext/presentation/screen/TaskScreen.kt | 29 +++++-
.../presentation/viewmodel/MainViewModel.kt | 24 +++++
.../presentation/viewmodel/TaskViewModel.kt | 5 +-
19 files changed, 402 insertions(+), 126 deletions(-)
create mode 100644 .idea/androidTestResultsUserPreferences.xml
create mode 100644 donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/repository/TaskDaoTest.kt
rename donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/{TaskListWIthOverdue.kt => TaskListWithOverdue.kt} (100%)
create mode 100644 donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/ManageListsScreen.kt
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
new file mode 100644
index 0000000..42464aa
--- /dev/null
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index b03c5cb..98cd5b2 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -8,6 +8,9 @@
+
+
+
\ No newline at end of file
diff --git a/donextv2/build.gradle.kts b/donextv2/build.gradle.kts
index 34aaf90..c801980 100644
--- a/donextv2/build.gradle.kts
+++ b/donextv2/build.gradle.kts
@@ -55,6 +55,7 @@ dependencies {
implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:2.9.4")
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("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
diff --git a/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/repository/TaskDaoTest.kt b/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/repository/TaskDaoTest.kt
new file mode 100644
index 0000000..4e7e349
--- /dev/null
+++ b/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/repository/TaskDaoTest.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/Database.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/Database.kt
index 179aed2..bb45566 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/Database.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/Database.kt
@@ -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")
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt
index 7f7e79a..64497fe 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt
@@ -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>
@Insert(onConflict = OnConflictStrategy.REPLACE)
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt
index 3cc608b..45587f3 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt
@@ -15,14 +15,14 @@ interface TaskListDao {
fun getTaskLists(): Flow>
@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>
+ fun getTaskListsWithOverdue(nowMillis: Long): Flow>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTaskList(taskList: TaskListEntity)
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt
index 00346ff..523ea52 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt
@@ -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> {
- val todayMillis = LocalDate.now()
- .atStartOfDay(ZoneOffset.UTC)
- .toInstant()
- .toEpochMilli()
-
- return taskListDao.getTaskListsWithOverdue(todayMillis)
+ override fun getTaskListsWithOverdue(nowMillis: Long): Flow> {
+ return taskListDao.getTaskListsWithOverdue(nowMillis).map { it }
}
-
}
\ No newline at end of file
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskListWIthOverdue.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskListWithOverdue.kt
similarity index 100%
rename from donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskListWIthOverdue.kt
rename to donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskListWithOverdue.kt
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt
index 0ba1cb9..ea0d982 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt
@@ -15,5 +15,5 @@ interface TaskRepository {
fun getTaskLists(): Flow>
suspend fun insertTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
- fun getTaskListsWithOverdue(): Flow>
+ fun getTaskListsWithOverdue(nowMillis: Long): Flow>
}
\ No newline at end of file
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCase.kt
index 336add0..23c4f67 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCase.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTaskListsWithOverdueUseCase.kt
@@ -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> {
- return taskRepository.getTaskListsWithOverdue()
+ return taskRepository.getTaskListsWithOverdue(Instant.parse("2025-09-15T12:00:00Z").toEpochMilli())
}
}
\ No newline at end of file
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskUseCase.kt
index 0773cf9..5ac75a7 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskUseCase.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskUseCase.kt
@@ -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
)
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt
index de65908..4f0a20d 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt
@@ -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()
- MainScreen(
- viewModel,
- modifier = Modifier.padding(innerPadding)
- )
- }
- }
+ DoNextTheme { MainScreen() }
}
}
}
\ No newline at end of file
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt
index 8aad423..c485a0b 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt
@@ -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()) {
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() }
+ )
+ }
}
}
}
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/ManageListsScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/ManageListsScreen.kt
new file mode 100644
index 0000000..cb702fe
--- /dev/null
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/ManageListsScreen.kt
@@ -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()
+ }
+ }
+}
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt
index 093ae3f..8919300 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt
@@ -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(), 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()))
}
}
\ No newline at end of file
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt
index 784cc83..7617cf6 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt
@@ -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")
}
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt
index 2e70238..c1c06c5 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt
@@ -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>(emptyList())
private set
+ val destinations: List
+ get() = taskLists.map { AppDestination.TaskList(it.id, it.name) } +
+ AppDestination.ManageLists
var isLoading by mutableStateOf(true)
private set
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt
index d058ae7..df6bced 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt
@@ -29,6 +29,8 @@ class TaskViewModel @Inject constructor(
private set
var dueDate by mutableStateOf(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)
}