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) }