mirror of
https://github.com/wismna/DoNext.git
synced 2025-10-03 07:30:13 -04:00
Compare commits
5 Commits
ef73319613
...
83f441a618
Author | SHA1 | Date | |
---|---|---|---|
![]() |
83f441a618 | ||
![]() |
80f86ebdee | ||
![]() |
e250ac91d0 | ||
![]() |
cc25aa4b05 | ||
![]() |
7939257cd6 |
@@ -47,15 +47,15 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3")
|
||||||
implementation("androidx.activity:activity-compose:1.10.1")
|
implementation("androidx.activity:activity-compose:1.11.0")
|
||||||
implementation(platform("androidx.compose:compose-bom:2025.08.01"))
|
implementation(platform("androidx.compose:compose-bom:2025.09.00"))
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.ui:ui-graphics")
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.navigation:navigation-compose:2.9.4")
|
implementation("androidx.navigation:navigation-compose:2.9.4")
|
||||||
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
|
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
|
||||||
androidTestImplementation(platform("androidx.compose:compose-bom:2025.08.01"))
|
androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.00"))
|
||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
@@ -2,19 +2,8 @@ package com.wismna.geoffroy.donext.data
|
|||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import com.wismna.geoffroy.donext.domain.model.Priority
|
import com.wismna.geoffroy.donext.domain.model.Priority
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
@TypeConverter
|
|
||||||
fun fromTimestamp(value: Long?): Instant? {
|
|
||||||
return value?.let { Instant.ofEpochMilli(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@TypeConverter
|
|
||||||
fun instantToTimestamp(instant: Instant?): Long? {
|
|
||||||
return instant?.toEpochMilli()
|
|
||||||
}
|
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun fromPriority(priority: Priority): Int = priority.value
|
fun fromPriority(priority: Priority): Int = priority.value
|
||||||
|
|
||||||
|
@@ -4,7 +4,6 @@ import androidx.room.ColumnInfo
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.wismna.geoffroy.donext.domain.model.Priority
|
import com.wismna.geoffroy.donext.domain.model.Priority
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
@Entity(tableName = "tasks")
|
@Entity(tableName = "tasks")
|
||||||
data class TaskEntity(
|
data class TaskEntity(
|
||||||
@@ -20,5 +19,5 @@ data class TaskEntity(
|
|||||||
@ColumnInfo(name = "task_list_id")
|
@ColumnInfo(name = "task_list_id")
|
||||||
val taskListId: Long,
|
val taskListId: Long,
|
||||||
@ColumnInfo(name = "due_date")
|
@ColumnInfo(name = "due_date")
|
||||||
val dueDate: Instant? = null
|
val dueDate: Long? = null
|
||||||
)
|
)
|
||||||
|
@@ -1,7 +1,5 @@
|
|||||||
package com.wismna.geoffroy.donext.domain.model
|
package com.wismna.geoffroy.donext.domain.model
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
data class Task(
|
data class Task(
|
||||||
val id: Long? = null,
|
val id: Long? = null,
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -10,5 +8,5 @@ data class Task(
|
|||||||
val isDone: Boolean,
|
val isDone: Boolean,
|
||||||
val isDeleted: Boolean,
|
val isDeleted: Boolean,
|
||||||
val taskListId: Long,
|
val taskListId: Long,
|
||||||
val dueDate: Instant? = null
|
val dueDate: Long? = null
|
||||||
)
|
)
|
||||||
|
@@ -8,7 +8,7 @@ import javax.inject.Inject
|
|||||||
class AddTaskUseCase @Inject constructor(
|
class AddTaskUseCase @Inject constructor(
|
||||||
private val repository: TaskRepository
|
private val repository: TaskRepository
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(taskListId: Long, title: String, description: String?, priority: Priority) {
|
suspend operator fun invoke(taskListId: Long, title: String, description: String?, priority: Priority, dueDate: Long?) {
|
||||||
repository.insertTask(
|
repository.insertTask(
|
||||||
Task(
|
Task(
|
||||||
taskListId = taskListId,
|
taskListId = taskListId,
|
||||||
@@ -17,6 +17,7 @@ class AddTaskUseCase @Inject constructor(
|
|||||||
isDeleted = false,
|
isDeleted = false,
|
||||||
isDone = false,
|
isDone = false,
|
||||||
priority = priority,
|
priority = priority,
|
||||||
|
dueDate = dueDate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,12 @@
|
|||||||
|
package com.wismna.geoffroy.donext.domain.usecase
|
||||||
|
|
||||||
|
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DeleteTaskUseCase @Inject constructor(
|
||||||
|
private val repository: TaskRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(taskId: Long) {
|
||||||
|
repository.deleteTask(taskId, true)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,25 @@
|
|||||||
|
package com.wismna.geoffroy.donext.domain.usecase
|
||||||
|
|
||||||
|
import com.wismna.geoffroy.donext.domain.model.Priority
|
||||||
|
import com.wismna.geoffroy.donext.domain.model.Task
|
||||||
|
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
|
||||||
|
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?) {
|
||||||
|
repository.updateTask(
|
||||||
|
Task(
|
||||||
|
id = taskId,
|
||||||
|
taskListId = taskListId,
|
||||||
|
name = title,
|
||||||
|
description = description ?: "",
|
||||||
|
isDeleted = false,
|
||||||
|
isDone = false,
|
||||||
|
priority = priority,
|
||||||
|
dueDate = dueDate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import com.wismna.geoffroy.donext.presentation.screen.MainScreen
|
import com.wismna.geoffroy.donext.presentation.screen.MainScreen
|
||||||
import com.wismna.geoffroy.donext.presentation.ui.theme.DoNextTheme
|
import com.wismna.geoffroy.donext.presentation.ui.theme.DoNextTheme
|
||||||
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
|
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
|
||||||
|
@@ -3,22 +3,16 @@
|
|||||||
package com.wismna.geoffroy.donext.presentation.screen
|
package com.wismna.geoffroy.donext.presentation.screen
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.PrimaryTabRow
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
@@ -27,7 +21,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -36,21 +29,16 @@ 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.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
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.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.wismna.geoffroy.donext.domain.model.Priority
|
import com.wismna.geoffroy.donext.domain.model.Priority
|
||||||
import com.wismna.geoffroy.donext.domain.model.TaskList
|
|
||||||
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
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
@@ -65,18 +53,21 @@ fun MainScreen(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
val taskViewModel: TaskViewModel = hiltViewModel()
|
||||||
val startDestination = viewModel.taskLists[0]
|
val startDestination = viewModel.taskLists[0]
|
||||||
// TODO: get last opened tab from saved settings
|
// TODO: get last opened tab from saved settings
|
||||||
var selectedDestination by rememberSaveable { mutableIntStateOf(0) }
|
var selectedDestination by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
|
||||||
if (showBottomSheet) {
|
if (showBottomSheet) {
|
||||||
AddTaskBottomSheet(viewModel, selectedDestination, { showBottomSheet = false })
|
TaskBottomSheet(taskViewModel, { showBottomSheet = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AddNewTaskButton {
|
AddNewTaskButton {
|
||||||
|
val currentListId = viewModel.taskLists[selectedDestination].id
|
||||||
|
taskViewModel.startNewTask(currentListId)
|
||||||
showBottomSheet = true
|
showBottomSheet = true
|
||||||
}
|
}
|
||||||
}, topBar = {
|
}, topBar = {
|
||||||
@@ -89,11 +80,19 @@ fun MainScreen(
|
|||||||
selectedDestination = index
|
selectedDestination = index
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
|
/*BadgedBox(
|
||||||
|
badge = {
|
||||||
|
if (overdueCount > 0) {
|
||||||
|
Badge { Text(overdueCount.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {*/
|
||||||
Text(
|
Text(
|
||||||
text = destination.name,
|
text = destination.name,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
//}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -102,18 +101,6 @@ fun MainScreen(
|
|||||||
Box(modifier = Modifier
|
Box(modifier = Modifier
|
||||||
.padding(contentPadding)
|
.padding(contentPadding)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
|
||||||
AppNavHost(navController, startDestination, viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AppNavHost(
|
|
||||||
navController: NavHostController,
|
|
||||||
startDestination: TaskList,
|
|
||||||
viewModel: MainViewModel
|
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController,
|
navController,
|
||||||
@@ -122,77 +109,18 @@ fun AppNavHost(
|
|||||||
viewModel.taskLists.forEach { destination ->
|
viewModel.taskLists.forEach { destination ->
|
||||||
composable(
|
composable(
|
||||||
route = "taskList/{taskListId}",
|
route = "taskList/{taskListId}",
|
||||||
arguments = listOf(navArgument("taskListId") { type = NavType.LongType })) {
|
arguments = listOf(navArgument("taskListId") {
|
||||||
val viewModel: TaskListViewModel = hiltViewModel<TaskListViewModel>()
|
type = NavType.LongType
|
||||||
TaskListScreen(viewModel)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AddTaskBottomSheet(
|
|
||||||
viewModel: MainViewModel,
|
|
||||||
selectedListIndex: Int,
|
|
||||||
onDismiss: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val titleFocusRequester = remember { FocusRequester() }
|
TaskListScreen(
|
||||||
|
onTaskClick = { task ->
|
||||||
LaunchedEffect(Unit) {
|
taskViewModel.startEditTask(task)
|
||||||
titleFocusRequester.requestFocus()
|
showBottomSheet = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
|
||||||
Column(Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
"New Task",
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// --- Title ---
|
|
||||||
OutlinedTextField(
|
|
||||||
value = viewModel.title,
|
|
||||||
singleLine = true,
|
|
||||||
onValueChange = { viewModel.onTitleChanged(it) },
|
|
||||||
label = { Text("Title") },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.focusRequester(titleFocusRequester),
|
|
||||||
isError = !viewModel.isTitleValid && viewModel.title.isNotEmpty(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// --- Description ---
|
|
||||||
OutlinedTextField(
|
|
||||||
value = viewModel.description,
|
|
||||||
onValueChange = { viewModel.onDescriptionChanged(it) },
|
|
||||||
label = { Text("Description") },
|
|
||||||
maxLines = 3,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// --- Priority ---
|
|
||||||
Text("Priority", style = MaterialTheme.typography.labelLarge)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
SingleChoiceSegmentedButton(
|
|
||||||
value = viewModel.priority,
|
|
||||||
onValueChange = { viewModel.onPriorityChanged(it) }
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// --- Add Button ---
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val currentListId = viewModel.taskLists[selectedListIndex].id
|
|
||||||
viewModel.createTask(currentListId)
|
|
||||||
onDismiss()
|
|
||||||
viewModel.resetTaskForm()
|
|
||||||
},
|
|
||||||
modifier = Modifier.align(Alignment.End)
|
|
||||||
) {
|
|
||||||
Text("Add")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
package com.wismna.geoffroy.donext.presentation.screen
|
package com.wismna.geoffroy.donext.presentation.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -16,6 +17,7 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
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.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -23,13 +25,16 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import com.wismna.geoffroy.donext.domain.model.Priority
|
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.TaskListViewModel
|
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) {
|
fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel(), onTaskClick: (Task) -> Unit) {
|
||||||
val tasks = viewModel.tasks
|
val tasks = viewModel.tasks
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -58,6 +63,7 @@ fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) {
|
|||||||
|
|
||||||
TaskItem(
|
TaskItem(
|
||||||
task = task,
|
task = task,
|
||||||
|
onClick = { onTaskClick(task) },
|
||||||
onToggleDone = { isChecked ->
|
onToggleDone = { isChecked ->
|
||||||
viewModel.updateTaskDone(task.id!!, isChecked)
|
viewModel.updateTaskDone(task.id!!, isChecked)
|
||||||
}
|
}
|
||||||
@@ -69,15 +75,26 @@ fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun TaskItem(
|
fun TaskItem(
|
||||||
task: Task,
|
task: Task,
|
||||||
|
onClick: () -> Unit,
|
||||||
onToggleDone: (Boolean) -> Unit
|
onToggleDone: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val today = remember {
|
||||||
|
LocalDate.now(ZoneOffset.UTC)
|
||||||
|
}
|
||||||
|
val isOverdue = task.dueDate?.let { millis ->
|
||||||
|
val dueDate = Instant.ofEpochMilli(millis)
|
||||||
|
.atZone(ZoneOffset.UTC)
|
||||||
|
.toLocalDate()
|
||||||
|
dueDate.isBefore(today)
|
||||||
|
} ?: false
|
||||||
|
|
||||||
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
|
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
fontWeight = when (task.priority) {
|
fontWeight = when (task.priority) {
|
||||||
Priority.HIGH -> FontWeight.Bold
|
Priority.HIGH -> FontWeight.Bold
|
||||||
Priority.NORMAL -> FontWeight.Normal
|
Priority.NORMAL -> FontWeight.Normal
|
||||||
Priority.LOW -> FontWeight.Normal
|
Priority.LOW -> FontWeight.Normal
|
||||||
},
|
},
|
||||||
color = when (task.priority) {
|
color = if (isOverdue && !task.isDone) MaterialTheme.colorScheme.error else when (task.priority) {
|
||||||
Priority.HIGH -> MaterialTheme.colorScheme.onSurface
|
Priority.HIGH -> MaterialTheme.colorScheme.onSurface
|
||||||
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
|
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
|
||||||
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
|
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -87,15 +104,10 @@ fun TaskItem(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() }
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
.alpha(if (task.isDone) 0.5f else 1f),
|
.alpha(if (task.isDone || task.priority == Priority.LOW) 0.5f else 1f),
|
||||||
) {
|
) {
|
||||||
/*Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {*/
|
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = task.isDone,
|
checked = task.isDone,
|
||||||
onCheckedChange = onToggleDone,
|
onCheckedChange = onToggleDone,
|
||||||
|
@@ -0,0 +1,175 @@
|
|||||||
|
package com.wismna.geoffroy.donext.presentation.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
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.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TaskBottomSheet(
|
||||||
|
viewModel: TaskViewModel,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val titleFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
titleFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
"New Task",
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// --- Title ---
|
||||||
|
OutlinedTextField(
|
||||||
|
value = viewModel.title,
|
||||||
|
singleLine = true,
|
||||||
|
onValueChange = { viewModel.onTitleChanged(it) },
|
||||||
|
label = { Text("Title") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(titleFocusRequester),
|
||||||
|
isError = viewModel.title.isEmpty(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// --- Description ---
|
||||||
|
OutlinedTextField(
|
||||||
|
value = viewModel.description,
|
||||||
|
onValueChange = { viewModel.onDescriptionChanged(it) },
|
||||||
|
label = { Text("Description") },
|
||||||
|
maxLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// --- Priority ---
|
||||||
|
Row (
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 17.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Priority", style = MaterialTheme.typography.labelLarge)
|
||||||
|
SingleChoiceSegmentedButton(
|
||||||
|
value = viewModel.priority,
|
||||||
|
onValueChange = { viewModel.onPriorityChanged(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// --- Due Date ---
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
val formattedDate = viewModel.dueDate?.let {
|
||||||
|
Instant.ofEpochMilli(it)
|
||||||
|
.atZone(ZoneOffset.UTC)
|
||||||
|
.toLocalDate()
|
||||||
|
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
|
||||||
|
} ?: ""
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = formattedDate,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Due Date") },
|
||||||
|
trailingIcon = {
|
||||||
|
Row {
|
||||||
|
if (viewModel.dueDate != null) {
|
||||||
|
IconButton(onClick = { viewModel.onDueDateChanged(null) }) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = "Clear due date")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showDatePicker = true }) {
|
||||||
|
Icon(Icons.Default.DateRange, contentDescription = "Pick due date")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = viewModel.dueDate)
|
||||||
|
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showDatePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
datePickerState.selectedDateMillis?.let { viewModel.onDueDateChanged(it) }
|
||||||
|
showDatePicker = false
|
||||||
|
}) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
DatePicker(state = datePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row (
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = if (viewModel.isEditing()) Arrangement.SpaceBetween else Arrangement.End) {
|
||||||
|
// --- Delete Button ---
|
||||||
|
if (viewModel.isEditing()) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.delete(); onDismiss() },
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) { Text("Delete") }
|
||||||
|
}
|
||||||
|
// --- Save Button ---
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.save()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
enabled = viewModel.title.isNotBlank(),
|
||||||
|
//modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text(if (viewModel.isEditing()) "Save" else "Create")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -5,20 +5,16 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.wismna.geoffroy.donext.domain.model.Priority
|
|
||||||
import com.wismna.geoffroy.donext.domain.model.TaskList
|
import com.wismna.geoffroy.donext.domain.model.TaskList
|
||||||
import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase
|
|
||||||
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
|
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MainViewModel @Inject constructor(
|
class MainViewModel @Inject constructor(
|
||||||
getTaskLists: GetTaskListsUseCase,
|
getTaskLists: GetTaskListsUseCase
|
||||||
private val addTask: AddTaskUseCase
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
var taskLists by mutableStateOf<List<TaskList>>(emptyList())
|
var taskLists by mutableStateOf<List<TaskList>>(emptyList())
|
||||||
@@ -26,17 +22,6 @@ class MainViewModel @Inject constructor(
|
|||||||
var isLoading by mutableStateOf(true)
|
var isLoading by mutableStateOf(true)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
// --- Form state ---
|
|
||||||
var title by mutableStateOf("")
|
|
||||||
private set
|
|
||||||
var description by mutableStateOf("")
|
|
||||||
private set
|
|
||||||
var priority by mutableStateOf(Priority.NORMAL)
|
|
||||||
private set
|
|
||||||
|
|
||||||
val isTitleValid: Boolean
|
|
||||||
get() = title.isNotBlank()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
getTaskLists()
|
getTaskLists()
|
||||||
.onEach { lists ->
|
.onEach { lists ->
|
||||||
@@ -45,27 +30,4 @@ class MainViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createTask(taskListId: Long) {
|
|
||||||
if (!isTitleValid) return
|
|
||||||
viewModelScope.launch {
|
|
||||||
addTask(taskListId, title, description, priority)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onTitleChanged(newTitle: String) {
|
|
||||||
title = newTitle
|
|
||||||
}
|
|
||||||
fun onDescriptionChanged(newDesc: String) {
|
|
||||||
description = newDesc
|
|
||||||
}
|
|
||||||
fun onPriorityChanged(newPriority: Priority) {
|
|
||||||
priority = newPriority
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetTaskForm() {
|
|
||||||
title = ""
|
|
||||||
description = ""
|
|
||||||
priority = Priority.NORMAL
|
|
||||||
}
|
|
||||||
}
|
}
|
@@ -0,0 +1,92 @@
|
|||||||
|
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.Priority
|
||||||
|
import com.wismna.geoffroy.donext.domain.model.Task
|
||||||
|
import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase
|
||||||
|
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskUseCase
|
||||||
|
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskUseCase
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class TaskViewModel @Inject constructor(
|
||||||
|
private val createTaskUseCase: AddTaskUseCase,
|
||||||
|
private val updateTaskUseCase: UpdateTaskUseCase,
|
||||||
|
private val deleteTaskUseCase: DeleteTaskUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
var title by mutableStateOf("")
|
||||||
|
private set
|
||||||
|
var description by mutableStateOf("")
|
||||||
|
private set
|
||||||
|
var priority by mutableStateOf(Priority.NORMAL)
|
||||||
|
private set
|
||||||
|
var dueDate by mutableStateOf<Long?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var editingTaskId: Long? = null
|
||||||
|
private var taskListId: Long? = null
|
||||||
|
|
||||||
|
fun isEditing(): Boolean = editingTaskId != null
|
||||||
|
|
||||||
|
fun startNewTask(selectedListId: Long) {
|
||||||
|
editingTaskId = null
|
||||||
|
taskListId = selectedListId
|
||||||
|
title = ""
|
||||||
|
description = ""
|
||||||
|
priority = Priority.NORMAL
|
||||||
|
dueDate = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startEditTask(task: Task) {
|
||||||
|
editingTaskId = task.id
|
||||||
|
taskListId = task.taskListId
|
||||||
|
title = task.name
|
||||||
|
description = task.description ?: ""
|
||||||
|
priority = task.priority
|
||||||
|
dueDate = task.dueDate
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTitleChanged(value: String) { title = value }
|
||||||
|
fun onDescriptionChanged(value: String) { description = value }
|
||||||
|
fun onPriorityChanged(value: Priority) { priority = value }
|
||||||
|
fun onDueDateChanged(value: Long?) { dueDate = value }
|
||||||
|
|
||||||
|
fun save(onDone: (() -> Unit)? = null) {
|
||||||
|
if (title.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (isEditing()) {
|
||||||
|
updateTaskUseCase(editingTaskId!!, taskListId!!, title, description, priority, dueDate)
|
||||||
|
} else {
|
||||||
|
createTaskUseCase(taskListId!!, title, description, priority, dueDate)
|
||||||
|
}
|
||||||
|
// reset state after save
|
||||||
|
reset()
|
||||||
|
onDone?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete() {
|
||||||
|
editingTaskId?.let { id ->
|
||||||
|
viewModelScope.launch {
|
||||||
|
deleteTaskUseCase(id)
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
editingTaskId = null
|
||||||
|
taskListId = null
|
||||||
|
title = ""
|
||||||
|
description = ""
|
||||||
|
priority = Priority.NORMAL
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user