Add create task lists button (WIP)

Implement delete task list button
Set theme colors
Add animations
Refactor main screen
Refactor task item with a view model
Put Edit Lists at the bottom of the Navigation Drawer
This commit is contained in:
Geoffroy Bonneville
2025-09-16 17:18:01 -04:00
parent 1692a197f2
commit 926a9bf66b
17 changed files with 566 additions and 314 deletions

View File

@@ -4,5 +4,4 @@ import android.app.Application
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp @HiltAndroidApp
class DonextApplication: Application() { class DonextApplication: Application()
}

View File

@@ -35,7 +35,7 @@ fun TaskListEntity.toDomain() = TaskList(
) )
fun TaskList.toEntity() = TaskListEntity( fun TaskList.toEntity() = TaskListEntity(
id = id, id = id ?: 0,
name = name, name = name,
isDeleted = isDeleted, isDeleted = isDeleted,
order = order order = order

View File

@@ -25,6 +25,6 @@ interface TaskDao {
@Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskId") @Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskId")
suspend fun markTaskDeleted(taskId: Long, deleted: Boolean) suspend fun markTaskDeleted(taskId: Long, deleted: Boolean)
@Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskListId") @Query("UPDATE tasks SET deleted = :deleted WHERE task_list_id = :taskListId")
suspend fun deleteAllTasksFromList(taskListId: Long, deleted: Boolean) suspend fun deleteAllTasksFromList(taskListId: Long, deleted: Boolean)
} }

View File

@@ -29,6 +29,7 @@ interface TaskListDao {
), 0) AS overdueCount ), 0) AS overdueCount
FROM task_lists tl FROM task_lists tl
LEFT JOIN tasks t ON t.task_list_id = tl.id LEFT JOIN tasks t ON t.task_list_id = tl.id
WHERE tl.deleted = 0
GROUP BY tl.id GROUP BY tl.id
""") """)
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>

View File

@@ -1,7 +1,7 @@
package com.wismna.geoffroy.donext.domain.model package com.wismna.geoffroy.donext.domain.model
data class TaskList( data class TaskList(
val id: Long, val id: Long? = null,
val name: String, val name: String,
val isDeleted: Boolean, val isDeleted: Boolean,
val order: Int val order: Int

View File

@@ -0,0 +1,19 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class AddTaskListUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(title: String, order: Int) {
repository.insertTaskList(
TaskList(
name = title,
order = order,
isDeleted = false
)
)
}
}

View File

@@ -0,0 +1,12 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class DeleteTaskListUseCase@Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(taskListId: Long) {
repository.deleteTaskList(taskListId, true)
}
}

View File

@@ -2,220 +2,231 @@
package com.wismna.geoffroy.donext.presentation.screen package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.ArrowBack 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.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.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.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.Surface
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavBackStackEntry
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.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.TaskListWithOverdue
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed class AppDestination(
val route: String,
val title: String,
val showBackButton: Boolean = false,
val actions: @Composable (() -> Unit)? = null
) {
data class TaskList(val taskListId: Long, val name: String) : AppDestination(
route = "taskList/$taskListId",
title = name,
)
object ManageLists : AppDestination(
route = "manageLists",
title = "Manage Lists",
showBackButton = true,
actions = { ManageListsActions() }
)
}
@Composable @Composable
fun MainScreen( fun MainScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MainViewModel = hiltViewModel<MainViewModel>()) { viewModel: MainViewModel = hiltViewModel()
) {
val navController = rememberNavController() val navController = rememberNavController()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val taskViewModel: TaskViewModel = hiltViewModel()
if (viewModel.isLoading) { if (viewModel.isLoading) {
// Show loading or empty state
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() CircularProgressIndicator()
} }
} else { return
val taskViewModel: TaskViewModel = hiltViewModel()
val startDestination = viewModel.taskLists[0]
// TODO: get last opened tab from saved settings
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 })
} }
val firstListId = viewModel.taskLists.firstOrNull()?.id
if (showBottomSheet) {
TaskBottomSheet(taskViewModel) { showBottomSheet = false }
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = remember(navBackStackEntry, viewModel.taskLists) {
deriveDestination(navBackStackEntry, viewModel.taskLists)
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerContent = { drawerContent = {
ModalDrawerSheet { MenuScreen (
Text( taskLists = viewModel.taskLists,
text = "Task Lists", currentDestination = currentDestination,
style = MaterialTheme.typography.titleMedium, onNavigate = { route ->
modifier = Modifier.padding(16.dp)
)
viewModel.taskLists.forEachIndexed { index, destination ->
NavigationDrawerItem(
label = { Text(destination.name) },
icon = {
Icon(
imageVector = Icons.Default.List,
contentDescription = destination.name
)},
selected = selectedDestination == index,
onClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(route = "taskList/${destination.id}") navController.navigate(route) {
selectedDestination = index //launchSingleTop = true
}, restoreState = true
badge = {
if (destination.overdueCount > 0) {
Badge {
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 drawerState = drawerState
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
floatingActionButton = { containerColor = Color.Transparent,
if (!isManageLists) { topBar = {
AddNewTaskButton {
val currentListId = viewModel.taskLists[selectedDestination].id
taskViewModel.startNewTask(currentListId)
showBottomSheet = true
}
}
}, topBar = {
TopAppBar( TopAppBar(
title = { title = { Text(currentDestination.title) },
Text( colors = TopAppBarDefaults.topAppBarColors(
if (isManageLists) "Manage Lists" containerColor = MaterialTheme.colorScheme.primaryContainer,
else viewModel.taskLists.getOrNull(selectedDestination)?.name ?: "Tasks" titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
}, ),
navigationIcon = { navigationIcon = {
if (isManageLists) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (currentDestination.showBackButton) {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.Default.ArrowBack, contentDescription = "Back")
} }
} else { } 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 -> }
NavHost( }
navController, }
startDestination = "taskList/${startDestination.id}" },
actions = { currentDestination.actions?.invoke() }
)
},
floatingActionButton = {
when (val dest = currentDestination) {
is AppDestination.TaskList -> {
TaskListFab(
taskListId = dest.taskListId,
showBottomSheet = { showBottomSheet = it }
)
}
else -> null
}
}
) { contentPadding ->
Surface(
modifier = Modifier
.padding(top = contentPadding.calculateTopPadding())
.fillMaxSize(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) { ) {
viewModel.taskLists.forEach { destination -> NavHost(
navController = navController,
startDestination = firstListId?.let { "taskList/$it" }
?: AppDestination.ManageLists.route,
enterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(300))
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(300))
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
}
) {
viewModel.taskLists.forEach { list ->
composable( composable(
route = "taskList/{taskListId}", route = "taskList/{taskListId}",
arguments = listOf(navArgument("taskListId") { arguments = listOf(navArgument("taskListId") {
type = NavType.LongType type = NavType.LongType
}) })
) { ) { navBackStackEntry ->
val viewModel: TaskListViewModel = hiltViewModel(navBackStackEntry)
TaskListScreen( TaskListScreen(
modifier = Modifier.padding(contentPadding), viewModel = viewModel,
onTaskClick = { task -> onTaskClick = { task ->
taskViewModel.startEditTask(task) taskViewModel.startEditTask(task)
showBottomSheet = true showBottomSheet = true
})
} }
}
composable("manageLists") {
ManageListsScreen(
modifier = Modifier.padding(contentPadding),
onBackClick = { navController.popBackStack() }
) )
} }
} }
composable(AppDestination.ManageLists.route) {
ManageListsScreen(modifier = Modifier)
}
}
} }
} }
} }
} }
@Composable fun deriveDestination(
fun AddNewTaskButton(onClick: () -> Unit) { navBackStackEntry: NavBackStackEntry?,
ExtendedFloatingActionButton( taskLists: List<TaskListWithOverdue>
onClick = onClick, ): AppDestination {
icon = { Icon(Icons.Filled.Add, "Create a task.") }, val route = navBackStackEntry?.destination?.route
text = { Text(text = "Create a task") },
)
}
@Composable return when {
fun SingleChoiceSegmentedButton( route == AppDestination.ManageLists.route -> AppDestination.ManageLists
value: Priority, route?.startsWith("taskList/") == true || route == "taskList/{taskListId}" -> {
onValueChange: (Priority) -> Unit) { val idArg = navBackStackEntry.arguments?.getLong("taskListId")
val options = listOf(Priority.LOW.label, Priority.NORMAL.label, Priority.HIGH.label) val taskListId = idArg ?: route.substringAfter("taskList/", "").toLongOrNull()
val matching = taskLists.find { it.id == taskListId }
SingleChoiceSegmentedButtonRow { matching?.let { AppDestination.TaskList(it.id, it.name) }
options.forEachIndexed { index, label -> ?: taskLists.firstOrNull()?.let { AppDestination.TaskList(it.id, it.name) }
SegmentedButton( ?: AppDestination.ManageLists
shape = SegmentedButtonDefaults.itemShape( }
index = index, else -> {
count = options.size taskLists.firstOrNull()?.let { AppDestination.TaskList(it.id, it.name) }
), ?: AppDestination.ManageLists
onClick = { onValueChange(Priority.fromValue(index)) },
selected = index == value.value,
label = { Text(label) }
)
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.padding
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.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -13,37 +14,50 @@ 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.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ManageListsScreen( fun ManageListsScreen(
modifier: Modifier, modifier: Modifier,
viewModel: MainViewModel = hiltViewModel(), viewModel: ManageListsViewModel = hiltViewModel()
onBackClick: () -> Unit
) { ) {
val lists = viewModel.taskLists val lists = viewModel.taskLists
LazyColumn(modifier = modifier.fillMaxWidth().padding()) { LazyColumn(modifier = modifier.fillMaxWidth().padding()) {
itemsIndexed(lists, key = { _, list -> list.id }) { index, list -> itemsIndexed(lists, key = { _, list -> list.id!! }) { index, list ->
ListItem( ListItem(
headlineContent = { Text(list.name) }, headlineContent = { Text(list.name) },
trailingContent = { trailingContent = {
Row { Row {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
IconButton(onClick = { /* TODO: edit list */ }) { IconButton(onClick = { /* TODO: edit list */ }) {
Icon(Icons.Default.Edit, contentDescription = "Edit") Icon(Icons.Default.Edit, contentDescription = "Edit")
} }
IconButton(onClick = { /* TODO: delete list */ }) { IconButton(onClick = { viewModel.deleteTaskList(list.id!!) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete") Icon(Icons.Default.Delete, contentDescription = "Delete")
} }
} }
} }
}
) )
HorizontalDivider() HorizontalDivider()
} }
} }
} }
@Composable
fun ManageListsActions(
viewModel: ManageListsViewModel = hiltViewModel()
) {
IconButton(onClick = { viewModel.createTaskList("Test", 1) }) {
Icon(Icons.Default.Add, contentDescription = "Add List")
}
}

View File

@@ -0,0 +1,74 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List
import androidx.compose.material3.Badge
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
@Composable
fun MenuScreen(
taskLists: List<TaskListWithOverdue>,
currentDestination: AppDestination,
onNavigate: (String) -> Unit
) {
ModalDrawerSheet(
drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant,
drawerContentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "Task Lists",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
taskLists.forEach { list ->
NavigationDrawerItem(
label = { Text(list.name) },
icon = { Icon(Icons.Default.List, contentDescription = list.name) },
selected = currentDestination is AppDestination.TaskList &&
currentDestination.taskListId == list.id,
onClick = { onNavigate("taskList/${list.id}") },
badge = {
if (list.overdueCount > 0) {
Badge { Text(list.overdueCount.toString()) }
}
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
Column {
HorizontalDivider()
NavigationDrawerItem(
label = { Text("Edit Lists") },
icon = { Icon(Icons.Default.Edit, contentDescription = "Edit Lists") },
selected = currentDestination is AppDestination.ManageLists,
onClick = { onNavigate(AppDestination.ManageLists.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
}
}

View File

@@ -0,0 +1,132 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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
import androidx.compose.ui.unit.dp
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun TaskItemScreen(
modifier: Modifier = Modifier,
viewModel: TaskItemViewModel,
onClick: () -> Unit,
onToggleDone: (Boolean) -> Unit
) {
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (viewModel.priority) {
Priority.HIGH -> FontWeight.Bold
Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> FontWeight.Normal
},
color = when (viewModel.priority) {
Priority.HIGH -> MaterialTheme.colorScheme.onSurface
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
},
textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None)
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(8.dp)
.alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f),
verticalAlignment = Alignment.CenterVertically // centers checkbox + content
) {
// Done checkbox
Checkbox(
checked = viewModel.isDone,
onCheckedChange = onToggleDone,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
)
Box(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
.height(IntrinsicSize.Min) // shrink to fit title/description
) {
// Title
Text(
text = viewModel.name,
style = baseStyle,
modifier = Modifier
.align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterStart
else Alignment.TopStart
)
)
// Due date badge
viewModel.dueDateText?.let { dueMillis ->
Box(
modifier = Modifier
.align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterEnd
else Alignment.TopEnd
)
.background(
color = if (viewModel.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 = viewModel.dueDateText,
style = MaterialTheme.typography.bodySmall,
color = if (viewModel.isOverdue) Color.White else MaterialTheme.colorScheme.primary
)
}
}
// Optional description
this@Row.AnimatedVisibility(
visible = !viewModel.description.isNullOrBlank(),
modifier = Modifier.align(Alignment.BottomStart),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Text(
text = viewModel.description!!,
style = baseStyle.copy(fontSize = MaterialTheme.typography.bodyMedium.fontSize),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(top = 20.dp) // spacing below title
)
}
}
}
}

View File

@@ -1,55 +1,38 @@
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.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.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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
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.material.icons.Icons
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Checkbox import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.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
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import java.time.Instant import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
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 @Composable
fun TaskListScreen(modifier: Modifier = Modifier, viewModel: TaskListViewModel = hiltViewModel<TaskListViewModel>(), 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().padding() modifier = modifier.fillMaxSize().padding()
) { ) {
itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task -> itemsIndexed(tasks, key = { id, task -> task.id!! }) { index, task ->
if (index > 0) { if (index > 0) {
val prev = tasks[index - 1] val prev = tasks[index - 1]
@@ -70,8 +53,9 @@ fun TaskListScreen(modifier: Modifier = Modifier, viewModel: TaskListViewModel =
} }
} }
TaskItem( TaskItemScreen(
task = task, modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
onClick = { onTaskClick(task) }, onClick = { onTaskClick(task) },
onToggleDone = { isChecked -> onToggleDone = { isChecked ->
viewModel.updateTaskDone(task.id!!, isChecked) viewModel.updateTaskDone(task.id!!, isChecked)
@@ -82,104 +66,17 @@ fun TaskListScreen(modifier: Modifier = Modifier, viewModel: TaskListViewModel =
} }
@Composable @Composable
fun TaskItem( fun TaskListFab(
task: Task, taskListId: Long,
onClick: () -> Unit, viewModel: TaskViewModel = hiltViewModel(),
onToggleDone: (Boolean) -> Unit showBottomSheet: (Boolean) -> Unit = {}
) { ) {
val today = remember { ExtendedFloatingActionButton(
LocalDate.now(ZoneOffset.UTC) onClick = {
} viewModel.startNewTask(taskListId)
val isOverdue = task.dueDate?.let { millis -> showBottomSheet(true)
val dueDate = Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC)
.toLocalDate()
dueDate.isBefore(today)
} ?: false
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (task.priority) {
Priority.HIGH -> FontWeight.Bold
Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> FontWeight.Normal
}, },
color = when (task.priority) { icon = { Icon(Icons.Filled.Add, "Create a task.") },
Priority.HIGH -> MaterialTheme.colorScheme.onSurface text = { Text("Create a task") },
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()
.clickable { onClick() }
.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)
.clip(CircleShape)
)
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = task.name,
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()) {
Text(
text = task.description,
style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize
),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
private fun formatTaskDueDate(dueMillis: Long): String {
val today = LocalDate.now()
val dueDate = Instant.ofEpochMilli(dueMillis).atZone(ZoneOffset.UTC).toLocalDate()
return when {
dueDate.isEqual(today) -> "Today"
dueDate.isEqual(today.plusDays(1)) -> "Tomorrow"
dueDate.isEqual(today.minusDays(1)) -> "Yesterday"
dueDate.isAfter(today) && dueDate.isBefore(today.plusDays(7)) ->
dueDate.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault())
else ->
dueDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()))
}
}

View File

@@ -20,7 +20,10 @@ 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.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SelectableDates import androidx.compose.material3.SelectableDates
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
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
@@ -35,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wismna.geoffroy.donext.domain.model.Priority
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.LocalDate
@@ -192,3 +196,24 @@ fun TaskBottomSheet(
} }
} }
} }
@Composable
fun SingleChoiceSegmentedButton(
value: Priority,
onValueChange: (Priority) -> Unit) {
val options = listOf(Priority.LOW.label, Priority.NORMAL.label, Priority.HIGH.label)
SingleChoiceSegmentedButtonRow {
options.forEachIndexed { index, label ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size
),
onClick = { onValueChange(Priority.fromValue(index)) },
selected = index == value.value,
label = { Text(label) }
)
}
}
}

View File

@@ -12,27 +12,6 @@ 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
@@ -40,9 +19,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> /*val destinations: List<AppDestination>
get() = taskLists.map { AppDestination.TaskList(it.id, it.name) } + get() = taskLists.map { AppDestination.TaskList(it.id, it.name) } +
AppDestination.ManageLists AppDestination.ManageLists*/
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
private set private set

View File

@@ -0,0 +1,46 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ManageListsViewModel @Inject constructor(
getTaskListsUseCase: GetTaskListsUseCase,
private val addTaskListUseCase: AddTaskListUseCase,
private val deleteTaskListUseCase: DeleteTaskListUseCase
) : ViewModel() {
var taskLists by mutableStateOf<List<TaskList>>(emptyList())
private set
init {
getTaskListsUseCase()
.onEach { lists ->
taskLists = lists
}
.launchIn(viewModelScope)
}
fun createTaskList(title: String, order: Int) {
viewModelScope.launch {
addTaskListUseCase(title, order)
}
}
fun deleteTaskList(taskId: Long) {
viewModelScope.launch {
deleteTaskListUseCase(taskId)
}
}
}

View File

@@ -0,0 +1,44 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
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
class TaskItemViewModel(task: Task) {
val id: Long = task.id!!
val name: String = task.name
val description: String? = task.description
val isDone: Boolean = task.isDone
val priority: Priority = task.priority
val today: LocalDate = LocalDate.now(ZoneOffset.UTC)
val isOverdue: Boolean = task.dueDate?.let { millis ->
val dueDate = Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC)
.toLocalDate()
dueDate.isBefore(today)
} ?: false
val dueDateText: String? = task.dueDate?.let { formatDueDate(it) }
private fun formatDueDate(dueMillis: Long): String {
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

@@ -24,7 +24,6 @@ class TaskListViewModel @Inject constructor(
var tasks by mutableStateOf<List<Task>>(emptyList()) var tasks by mutableStateOf<List<Task>>(emptyList())
private set private set
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
private set private set