Compare commits

..

6 Commits

Author SHA1 Message Date
Geoffroy Bonneville
7dddc62377 Recycle Bin displays tasks grouped by list
Restoring a task from a deleted list restores the list
Removed Delete button from task sheet
Added Cancel button in task sheet
Task sheet is read-only in the Recycle Bin only
Empty Recycle Bin displays a confirmation
Empty Recycle Bin is now an IconButton
2025-10-02 21:09:51 -04:00
Geoffroy Bonneville
8e5ac4fedc Add extended icons
Some layout improvements
Update README
2025-10-01 21:35:25 -04:00
Geoffroy Bonneville
906ad0854d Fix gradle.properties 2025-09-26 20:37:33 -04:00
Geoffroy Bonneville
02c985ab55 Increase gradle memory 2025-09-26 20:34:50 -04:00
Geoffroy Bonneville
92217c99d4 Add some text under swipe icons 2025-09-26 20:30:38 -04:00
Geoffroy Bonneville
4522296cf1 Fix date issues
Change primary theme color
2025-09-25 20:46:24 -04:00
28 changed files with 371 additions and 158 deletions

View File

@@ -87,6 +87,19 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2160" /> <option name="screenY" value="2160" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="Lenovo" />
<option name="codename" value="TB330FU" />
<option name="formFactor" value="Tablet" />
<option name="id" value="TB330FU" />
<option name="labId" value="google" />
<option name="manufacturer" value="Lenovo" />
<option name="name" value="Tab M11" />
<option name="screenDensity" value="240" />
<option name="screenX" value="1200" />
<option name="screenY" value="1920" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="samsung" /> <option name="brand" value="samsung" />
@@ -366,18 +379,6 @@
<option name="screenX" value="384" /> <option name="screenX" value="384" />
<option name="screenY" value="384" /> <option name="screenY" value="384" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="motorola" />
<option name="codename" value="eqe" />
<option name="id" value="eqe" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="edge 50 pro" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1220" />
<option name="screenY" value="2712" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="33" /> <option name="api" value="33" />
<option name="brand" value="google" /> <option name="brand" value="google" />
@@ -560,6 +561,18 @@
<option name="screenX" value="720" /> <option name="screenX" value="720" />
<option name="screenY" value="1600" /> <option name="screenY" value="1600" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="motorola" />
<option name="codename" value="kansas" />
<option name="id" value="kansas" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g - 2025" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1604" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="google" /> <option name="brand" value="google" />

View File

@@ -2,10 +2,19 @@
[![Android CI](https://github.com/wismna/DoNext/actions/workflows/android.yaml/badge.svg)](https://github.com/wismna/DoNext/actions/workflows/android.yaml) [![Android CI](https://github.com/wismna/DoNext/actions/workflows/android.yaml/badge.svg)](https://github.com/wismna/DoNext/actions/workflows/android.yaml)
DoNext is an innovative task application. DoNext is an a task app that aims for simplicity.
You can create and arrange task lists, create, edit and delete tasks... You can create and arrange task lists, create, edit and delete tasks...
But the emphasis is not a long list, as the more you have to do, the less you actually do!
Instead, focus only on the first task on the list: Focus on what's important:
Swipe it to the left: it's done! - Tasks are sorted by priority
Swipe it to the right: "nexted!". It goes to the end of the list and you can work on the next task. - Easily see when each task is due, with a warning when it's overdue
- All tasks due today are grouped in a special view
## Technical stack
DoNext is made with:
- Kotlin
- Jetpack Compose for UI
- Hilt for dependency injection
- Room for the Database
- Clean Architecture

View File

@@ -22,7 +22,10 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false // Enables code-related app optimization.
isMinifyEnabled = true
// Enables resource shrinking.
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@@ -48,25 +51,26 @@ android {
dependencies { dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
implementation("androidx.activity:activity-compose:1.11.0") implementation("androidx.activity:activity-compose:1.11.0")
implementation(platform("androidx.compose:compose-bom:2025.09.00")) implementation(platform("androidx.compose:compose-bom:2025.09.01"))
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.compose.material:material-icons-extended:1.7.8")
implementation("androidx.navigation:navigation-compose:2.9.5")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0") implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("androidx.test.ext:junit-ktx:1.3.0") implementation("androidx.test.ext:junit-ktx:1.3.0")
implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("sh.calvin.reorderable:reorderable:3.0.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.00")) androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.01"))
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")
val roomVersion = "2.8.0" val roomVersion = "2.8.1"
implementation("androidx.room:room-runtime:$roomVersion") implementation("androidx.room:room-runtime:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion") ksp("androidx.room:room-compiler:$roomVersion")
val hiltVersion = "2.57.1" val hiltVersion = "2.57.2"
implementation("com.google.dagger:hilt-android:$hiltVersion") implementation("com.google.dagger:hilt-android:$hiltVersion")
ksp("com.google.dagger:hilt-android-compiler:$hiltVersion") ksp("com.google.dagger:hilt-android-compiler:$hiltVersion")
} }

View File

@@ -2,8 +2,10 @@ package com.wismna.geoffroy.donext.data
import com.wismna.geoffroy.donext.data.entities.TaskEntity import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.entities.TaskWithListNameEntity
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskWithListName
fun TaskEntity.toDomain() = Task( fun TaskEntity.toDomain() = Task(
id = id, id = id,
@@ -15,6 +17,12 @@ fun TaskEntity.toDomain() = Task(
dueDate = dueDate, dueDate = dueDate,
priority = priority, priority = priority,
) )
fun TaskWithListNameEntity.toDomain(): TaskWithListName {
return TaskWithListName(
task = task.toDomain(),
listName = listName
)
}
fun Task.toEntity() = TaskEntity( fun Task.toEntity() = TaskEntity(
id = id ?: 0, id = id ?: 0,

View File

@@ -0,0 +1,8 @@
package com.wismna.geoffroy.donext.data.entities
import androidx.room.Embedded
data class TaskWithListNameEntity(
@Embedded val task: TaskEntity,
val listName: String
)

View File

@@ -125,8 +125,8 @@ abstract class AppDatabase : RoomDatabase() {
// insert default lists // insert default lists
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val dao = DB_INSTANCE?.taskListDao() val dao = DB_INSTANCE?.taskListDao()
dao?.insertTaskList(TaskListEntity(name = "Work", order = 2))
dao?.insertTaskList(TaskListEntity(name = "Personal", order = 1)) dao?.insertTaskList(TaskListEntity(name = "Personal", order = 1))
dao?.insertTaskList(TaskListEntity(name = "Work", order = 2))
dao?.insertTaskList(TaskListEntity(name = "Shopping", order = 3)) dao?.insertTaskList(TaskListEntity(name = "Shopping", order = 3))
} }
} }

View File

@@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Update import androidx.room.Update
import com.wismna.geoffroy.donext.data.entities.TaskEntity import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskWithListNameEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@@ -20,8 +21,17 @@ interface TaskDao {
""") """)
fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<TaskEntity>> fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE deleted = 1") @Query("""
fun getDeletedTasks(): Flow<List<TaskEntity>> SELECT t.*, l.name AS listName
FROM tasks t
INNER JOIN task_lists l ON t.task_list_id = l.id
WHERE t.deleted = 1
ORDER BY l.name
""")
fun getDeletedTasksWithListName(): Flow<List<TaskWithListNameEntity>>
@Query("SELECT * FROM tasks WHERE id = :taskId")
suspend fun getTaskById(taskId: Long): TaskEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTask(task: TaskEntity) suspend fun insertTask(task: TaskEntity)

View File

@@ -35,6 +35,9 @@ interface TaskListDao {
""") """)
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
@Query("SELECT * FROM task_lists WHERE id = :taskListId")
suspend fun getTaskListById(taskListId: Long): TaskListEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTaskList(taskList: TaskListEntity) suspend fun insertTaskList(taskList: TaskListEntity)

View File

@@ -7,6 +7,7 @@ import com.wismna.geoffroy.donext.data.toEntity
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.model.TaskWithListName
import com.wismna.geoffroy.donext.domain.repository.TaskRepository import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -25,8 +26,12 @@ class TaskRepositoryImpl @Inject constructor(
return taskDao.getDueTodayTasks(todayStart, todayEnd).map {entity -> entity.map { it.toDomain() }} return taskDao.getDueTodayTasks(todayStart, todayEnd).map {entity -> entity.map { it.toDomain() }}
} }
override fun getDeletedTasks(): Flow<List<Task>> { override fun getDeletedTasks(): Flow<List<TaskWithListName>> {
return taskDao.getDeletedTasks().map {entity -> entity.map { it.toDomain() }} return taskDao.getDeletedTasksWithListName().map {entity -> entity.map { it.toDomain() }}
}
override suspend fun getTaskById(taskId: Long): Task? {
return taskDao.getTaskById(taskId)?.toDomain()
} }
override suspend fun insertTask(task: Task) { override suspend fun insertTask(task: Task) {
@@ -57,6 +62,10 @@ class TaskRepositoryImpl @Inject constructor(
return taskListDao.getTaskLists().map {entities -> entities.map { it.toDomain() }} return taskListDao.getTaskLists().map {entities -> entities.map { it.toDomain() }}
} }
override suspend fun getTaskListById(taskListId: Long): TaskList? {
return taskListDao.getTaskListById(taskListId)?.toDomain()
}
override suspend fun insertTaskList(taskList: TaskList) { override suspend fun insertTaskList(taskList: TaskList) {
taskListDao.insertTaskList(taskList.toEntity()) taskListDao.insertTaskList(taskList.toEntity())
} }

View File

@@ -0,0 +1,10 @@
package com.wismna.geoffroy.donext.domain.extension
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
fun Long.toLocalDate(): LocalDate =
Instant.ofEpochMilli(this)
.atZone(ZoneId.systemDefault())
.toLocalDate()

View File

@@ -0,0 +1,6 @@
package com.wismna.geoffroy.donext.domain.model
data class TaskWithListName (
val task: Task,
val listName: String
)

View File

@@ -3,12 +3,14 @@ package com.wismna.geoffroy.donext.domain.repository
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.model.TaskWithListName
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface TaskRepository { interface TaskRepository {
fun getTasksForList(listId: Long): Flow<List<Task>> fun getTasksForList(listId: Long): Flow<List<Task>>
fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<Task>> fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<Task>>
fun getDeletedTasks(): Flow<List<Task>> fun getDeletedTasks(): Flow<List<TaskWithListName>>
suspend fun getTaskById(taskId: Long): Task?
suspend fun insertTask(task: Task) suspend fun insertTask(task: Task)
suspend fun updateTask(task: Task) suspend fun updateTask(task: Task)
suspend fun toggleTaskDeleted(taskId: Long, isDeleted: Boolean) suspend fun toggleTaskDeleted(taskId: Long, isDeleted: Boolean)
@@ -17,6 +19,7 @@ interface TaskRepository {
suspend fun permanentlyDeleteAllDeletedTask() suspend fun permanentlyDeleteAllDeletedTask()
fun getTaskLists(): Flow<List<TaskList>> fun getTaskLists(): Flow<List<TaskList>>
suspend fun getTaskListById(taskListId: Long): TaskList?
suspend fun insertTaskList(taskList: TaskList) suspend fun insertTaskList(taskList: TaskList)
suspend fun updateTaskList(taskList: TaskList) suspend fun updateTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)

View File

@@ -1,10 +1,10 @@
package com.wismna.geoffroy.donext.domain.usecase package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.TaskWithListName
import com.wismna.geoffroy.donext.domain.repository.TaskRepository import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
class GetDeletedTasksUseCase @Inject constructor(private val repository: TaskRepository) { class GetDeletedTasksUseCase @Inject constructor(private val repository: TaskRepository) {
operator fun invoke(): Flow<List<Task>> = repository.getDeletedTasks() operator fun invoke(): Flow<List<TaskWithListName>> = repository.getDeletedTasks()
} }

View File

@@ -7,6 +7,17 @@ class ToggleTaskDeletedUseCase @Inject constructor(
private val repository: TaskRepository private val repository: TaskRepository
) { ) {
suspend operator fun invoke(taskId: Long, isDeleted: Boolean) { suspend operator fun invoke(taskId: Long, isDeleted: Boolean) {
if (!isDeleted) {
val task = repository.getTaskById(taskId)
if (task != null) {
// If task list was soft-deleted, restore it as well
val taskList = repository.getTaskListById(task.taskListId)
if (taskList != null && taskList.isDeleted) {
repository.updateTaskList(taskList.copy(isDeleted = false))
}
}
}
repository.toggleTaskDeleted(taskId, isDeleted) repository.toggleTaskDeleted(taskId, isDeleted)
} }
} }

View File

@@ -26,7 +26,6 @@ import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
@@ -61,6 +60,7 @@ fun MainScreen(
val navController = rememberNavController() val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// TODO: find a way to get rid of this
val taskViewModel: TaskViewModel = hiltViewModel() val taskViewModel: TaskViewModel = hiltViewModel()
if (viewModel.isLoading) { if (viewModel.isLoading) {
@@ -144,9 +144,7 @@ fun AppContent(
} }
} }
is AppDestination.RecycleBin -> { is AppDestination.RecycleBin -> {
TextButton(onClick = { viewModel.emptyRecycleBin() }) { EmptyRecycleBinAction()
Text(text = "Empty Recycle Bin", color = MaterialTheme.colorScheme.onPrimary)
}
} }
else -> null else -> null
} }
@@ -194,6 +192,7 @@ fun AppContent(
type = NavType.LongType type = NavType.LongType
}) })
) { navBackStackEntry -> ) { navBackStackEntry ->
// TODO: when task list has been deleted, we should not navigate to it event if in the stack
val taskListViewModel: TaskListViewModel = hiltViewModel(navBackStackEntry) val taskListViewModel: TaskListViewModel = hiltViewModel(navBackStackEntry)
TaskListScreen( TaskListScreen(
viewModel = taskListViewModel, viewModel = taskListViewModel,

View File

@@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -70,7 +71,7 @@ fun ManageListsScreen(
) )
LazyColumn( LazyColumn(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
state = lazyListState state = lazyListState
@@ -133,6 +134,7 @@ fun ManageListsScreen(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Default.DragHandle, contentDescription = "Edit")
AnimatedContent( AnimatedContent(
targetState = isInEditMode, targetState = isInEditMode,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -218,7 +220,7 @@ fun AddListBottomSheet(
//var description by remember { mutableStateOf("") } //var description by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text("Create New List", style = MaterialTheme.typography.titleMedium) Text("New List", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
/*TextField( /*TextField(
@@ -234,8 +236,7 @@ fun AddListBottomSheet(
label = { Text("Title") }, label = { Text("Title") },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(titleFocusRequester), .focusRequester(titleFocusRequester)
isError = name.isEmpty(),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -253,11 +254,14 @@ fun AddListBottomSheet(
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
//TextButton(onClick = onDismiss) { Text("Cancel") } //TextButton(onClick = onDismiss) { Text("Cancel") }
//Spacer(Modifier.width(8.dp)) //Spacer(Modifier.width(8.dp))
Button(onClick = { Button(
viewModel.createTaskList(name/*, type, description*/, 1) onClick = {
onDismiss() viewModel.createTaskList(name/*, type, description*/, viewModel.taskCount + 1)
}) { onDismiss()
Text("Add") },
enabled = name.isNotBlank()
) {
Text("Create")
} }
} }
} }

View File

@@ -7,10 +7,10 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
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.automirrored.filled.List
import androidx.compose.material.icons.filled.DateRange
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.EditNote
import androidx.compose.material.icons.filled.LineWeight
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -56,7 +56,7 @@ fun MenuScreen(
Text(viewModel.dueTodayTasksCount.toString()) Text(viewModel.dueTodayTasksCount.toString())
} }
}, },
icon = { Icon(Icons.Default.DateRange, contentDescription = "Due Today") }, icon = { Icon(Icons.Default.Today, contentDescription = "Due Today") },
selected = currentDestination is AppDestination.DueTodayList, selected = currentDestination is AppDestination.DueTodayList,
onClick = { onNavigate(AppDestination.DueTodayList.route) }, onClick = { onNavigate(AppDestination.DueTodayList.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
@@ -71,7 +71,7 @@ fun MenuScreen(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
}, },
icon = { Icon(Icons.AutoMirrored.Default.List, contentDescription = list.name) }, icon = { Icon(Icons.Default.LineWeight, contentDescription = list.name) },
selected = currentDestination is AppDestination.TaskList && selected = currentDestination is AppDestination.TaskList &&
currentDestination.taskListId == list.id, currentDestination.taskListId == list.id,
onClick = { onNavigate("taskList/${list.id}") }, onClick = { onNavigate("taskList/${list.id}") },
@@ -96,7 +96,7 @@ fun MenuScreen(
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Edit Lists") }, label = { Text("Edit Lists") },
icon = { Icon(Icons.Default.Edit, contentDescription = "Edit Lists") }, icon = { Icon(Icons.Default.EditNote, contentDescription = "Edit Lists") },
selected = currentDestination is AppDestination.ManageLists, selected = currentDestination is AppDestination.ManageLists,
onClick = { onNavigate(AppDestination.ManageLists.route) }, onClick = { onNavigate(AppDestination.ManageLists.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)

View File

@@ -3,14 +3,30 @@ package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast import android.widget.Toast
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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
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.Task import com.wismna.geoffroy.donext.domain.model.Task
@@ -33,28 +49,91 @@ fun RecycleBinScreen(
) { ) {
Text("Recycle Bin is empty") Text("Recycle Bin is empty")
} }
} else { return
val context = LocalContext.current }
LazyColumn(
modifier = modifier.padding(8.dp)
) {
items(tasks, key = { it.id!! }) { task ->
val grouped = tasks.groupBy { it.listName }
val context = LocalContext.current
LazyColumn(
modifier = modifier.padding(8.dp)
) {
// Deleted tasks are grouped by list name
grouped.forEach { (listName, items) ->
stickyHeader {
Surface(
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = listName,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.titleMedium.copy(
fontStyle = FontStyle.Italic
),
)
}
}
items(items, key = { it.task.id!! }) { item ->
TaskItemScreen( TaskItemScreen(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task), viewModel = TaskItemViewModel(item.task),
onTaskClick = { onTaskClick(task) }, onTaskClick = { onTaskClick(item.task) },
onSwipeLeft = { onSwipeLeft = {
viewModel.restore(task.id!!) viewModel.restore(item.task.id!!)
Toast.makeText(context, "Task restored", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Task restored", Toast.LENGTH_SHORT).show()
}, },
onSwipeRight = { onSwipeRight = {
viewModel.deleteForever(task.id!!) viewModel.deleteForever(item.task.id!!)
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show()
} }
) )
} }
} }
} }
} }
@Composable
fun EmptyRecycleBinAction(viewModel: RecycleBinViewModel = hiltViewModel()) {
val isEmpty = viewModel.deletedTasks.isEmpty()
var showConfirmDialog by remember { mutableStateOf(false) }
IconButton(
onClick = { showConfirmDialog = true },
enabled = !isEmpty) {
Icon(
Icons.Default.DeleteSweep,
modifier = Modifier.alpha(if (isEmpty) 0.5f else 1.0f),
contentDescription = "Empty Recycle Bin",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
if (showConfirmDialog) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
title = { Text("Empty Recycle Bin") },
text = {
Text("Are you sure you want to permanently delete all tasks in the recycle bin? This cannot be undone.")
},
confirmButton = {
TextButton(
onClick = {
viewModel.emptyRecycleBin()
showConfirmDialog = false
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) {
Text("Cancel")
}
}
)
}
}

View File

@@ -3,6 +3,7 @@ package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -11,10 +12,11 @@ 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.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.RestoreFromTrash
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Unpublished
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -30,7 +32,6 @@ import androidx.compose.ui.Alignment
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -47,6 +48,7 @@ fun TaskItemScreen(
onSwipeLeft: () -> Unit, onSwipeLeft: () -> Unit,
onSwipeRight: () -> Unit onSwipeRight: () -> Unit
) { ) {
// TODO: change this
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { confirmValueChange = {
when (it) { when (it) {
@@ -150,7 +152,6 @@ fun TaskItemScreen(
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
style = baseStyle.copy( style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize, fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontStyle = FontStyle.Italic
), ),
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@@ -179,16 +180,31 @@ fun DismissBackground(dismissState: SwipeToDismissBoxState, isDone: Boolean, isD
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Icon( Column (horizontalAlignment = Alignment.CenterHorizontally) {
if (isDeleted) Icons.Default.Clear else Icons.Default.Delete, Icon(
tint = Color.LightGray, if (isDeleted) Icons.Default.DeleteForever else Icons.Default.DeleteOutline,
contentDescription = "Delete" tint = Color.LightGray,
) contentDescription = "Delete"
)
Text(
color = MaterialTheme.colorScheme.onPrimary,
fontSize = 10.sp,
text = if (isDeleted) "Delete" else "Recycle"
)
}
Spacer(modifier = Modifier) Spacer(modifier = Modifier)
Icon( Column (horizontalAlignment = Alignment.CenterHorizontally) {
if (isDone) Icons.Default.Close else Icons.Default.Done, Icon(
tint = Color.LightGray, if (isDeleted) Icons.Default.RestoreFromTrash else
contentDescription = "Archive" if (isDone) Icons.Outlined.Unpublished else Icons.Outlined.CheckCircle,
) tint = Color.LightGray,
contentDescription = "Archive"
)
Text(
color = MaterialTheme.colorScheme.onPrimary,
fontSize = 10.sp,
text = if (isDeleted) "Restore" else if (isDone) "Undone" else "Done"
)
}
} }
} }

View File

@@ -8,8 +8,8 @@ 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.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
@@ -27,20 +27,23 @@ 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
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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.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.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.extension.toLocalDate
import com.wismna.geoffroy.donext.domain.model.Priority 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 kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -54,15 +57,19 @@ fun TaskBottomSheet(
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val titleFocusRequester = remember { FocusRequester() } val titleFocusRequester = remember { FocusRequester() }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
titleFocusRequester.requestFocus() titleFocusRequester.requestFocus()
} }
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
Text( Text(
if (viewModel.isEditing()) "Edit Task" else "New Task", viewModel.screenTitle(),
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -71,20 +78,19 @@ fun TaskBottomSheet(
OutlinedTextField( OutlinedTextField(
value = viewModel.title, value = viewModel.title,
singleLine = true, singleLine = true,
readOnly = viewModel.isDone, readOnly = viewModel.isDeleted,
onValueChange = { viewModel.onTitleChanged(it) }, onValueChange = { viewModel.onTitleChanged(it) },
label = { Text("Title") }, label = { Text("Title") },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(titleFocusRequester), .focusRequester(titleFocusRequester)
isError = viewModel.title.isEmpty(),
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// --- Description --- // --- Description ---
OutlinedTextField( OutlinedTextField(
value = viewModel.description, value = viewModel.description,
readOnly = viewModel.isDone, readOnly = viewModel.isDeleted,
onValueChange = { viewModel.onDescriptionChanged(it) }, onValueChange = { viewModel.onDescriptionChanged(it) },
label = { Text("Description") }, label = { Text("Description") },
maxLines = 3, maxLines = 3,
@@ -100,6 +106,7 @@ fun TaskBottomSheet(
Text("Priority", style = MaterialTheme.typography.labelLarge) Text("Priority", style = MaterialTheme.typography.labelLarge)
SingleChoiceSegmentedButton( SingleChoiceSegmentedButton(
value = viewModel.priority, value = viewModel.priority,
isEnabled = !viewModel.isDeleted,
onValueChange = { viewModel.onPriorityChanged(it) } onValueChange = { viewModel.onPriorityChanged(it) }
) )
} }
@@ -107,12 +114,9 @@ fun TaskBottomSheet(
// --- Due Date --- // --- Due Date ---
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
val formattedDate = viewModel.dueDate?.let { val formattedDate = viewModel.dueDate?.toLocalDate()?.format(
Instant.ofEpochMilli(it) DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
.atZone(ZoneId.systemDefault()) ?: ""
.toLocalDate()
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
} ?: ""
OutlinedTextField( OutlinedTextField(
value = formattedDate, value = formattedDate,
@@ -124,14 +128,14 @@ fun TaskBottomSheet(
if (viewModel.dueDate != null) { if (viewModel.dueDate != null) {
IconButton( IconButton(
onClick = { viewModel.onDueDateChanged(null) }, onClick = { viewModel.onDueDateChanged(null) },
enabled = !viewModel.isDone) { enabled = !viewModel.isDeleted) {
Icon(Icons.Default.Clear, contentDescription = "Clear due date") Icon(Icons.Default.Clear, contentDescription = "Clear due date")
} }
} }
IconButton( IconButton(
onClick = { showDatePicker = true }, onClick = { showDatePicker = true },
enabled = !viewModel.isDone) { enabled = !viewModel.isDeleted) {
Icon(Icons.Default.DateRange, contentDescription = "Pick due date") Icon(Icons.Default.CalendarMonth, contentDescription = "Pick due date")
} }
} }
}, },
@@ -143,11 +147,11 @@ fun TaskBottomSheet(
initialSelectedDateMillis = viewModel.dueDate, initialSelectedDateMillis = viewModel.dueDate,
selectableDates = object: SelectableDates { selectableDates = object: SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean { override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val todayStartMillis = LocalDate.now(ZoneOffset.UTC) val todayStartUtcMillis = LocalDate.now(ZoneId.systemDefault())
.atStartOfDay(ZoneOffset.UTC) .atStartOfDay(ZoneOffset.UTC)
.toInstant() .toInstant()
.toEpochMilli() .toEpochMilli()
return utcTimeMillis >= todayStartMillis return utcTimeMillis >= todayStartUtcMillis
} }
} }
) )
@@ -155,7 +159,8 @@ fun TaskBottomSheet(
DatePickerDialog( DatePickerDialog(
onDismissRequest = { showDatePicker = false }, onDismissRequest = { showDatePicker = false },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(
onClick = {
datePickerState.selectedDateMillis?.let { viewModel.onDueDateChanged(it) } datePickerState.selectedDateMillis?.let { viewModel.onDueDateChanged(it) }
showDatePicker = false showDatePicker = false
}) { Text("OK") } }) { Text("OK") }
@@ -167,31 +172,39 @@ fun TaskBottomSheet(
DatePicker(state = datePickerState) DatePicker(state = datePickerState)
} }
} }
if (!viewModel.isDeleted) {
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(16.dp)) Row (
modifier = Modifier.fillMaxWidth(),
Row ( horizontalArrangement = Arrangement.SpaceBetween
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() && !viewModel.isDone,
) { ) {
Text(if (viewModel.isEditing()) "Save" else "Create") // --- Cancel Button ---
Button(
onClick = {
scope.launch {
sheetState.hide()
onDismiss()
}
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
) { Text("Cancel") }
// --- Save Button ---
Button(
onClick = {
scope.launch {
viewModel.save()
sheetState.hide()
onDismiss()
}
},
enabled = viewModel.title.isNotBlank() && !viewModel.isDeleted,
) {
Text(if (viewModel.isEditing()) "Save" else "Create")
}
} }
} }
} }
@@ -201,6 +214,7 @@ fun TaskBottomSheet(
@Composable @Composable
fun SingleChoiceSegmentedButton( fun SingleChoiceSegmentedButton(
value: Priority, value: Priority,
isEnabled: Boolean,
onValueChange: (Priority) -> Unit) { onValueChange: (Priority) -> Unit) {
val options = listOf(Priority.LOW.label, Priority.NORMAL.label, Priority.HIGH.label) val options = listOf(Priority.LOW.label, Priority.NORMAL.label, Priority.HIGH.label)
@@ -211,6 +225,7 @@ fun SingleChoiceSegmentedButton(
index = index, index = index,
count = options.size count = options.size
), ),
enabled = isEnabled,
onClick = { onValueChange(Priority.fromValue(index)) }, onClick = { onValueChange(Priority.fromValue(index)) },
selected = index == value.value, selected = index == value.value,
label = { Text(label) } label = { Text(label) }

View File

@@ -3,7 +3,7 @@ package com.wismna.geoffroy.donext.presentation.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// Primary shades // Primary shades
val Purple80 = Color(0xFFD0BCFF) // Light theme primary val Purple80 = Color(0xFF6A59C7) // Light theme primary
val Purple40 = Color(0xFF6650A4) // Dark theme primary val Purple40 = Color(0xFF6650A4) // Dark theme primary
val PurpleGrey80 = Color(0xFFCCC2DC) val PurpleGrey80 = Color(0xFFCCC2DC)

View File

@@ -48,7 +48,7 @@ fun DoNextTheme(
else -> lightColorScheme( else -> lightColorScheme(
primary = Purple80, primary = Purple80,
onPrimary = DarkSurfaceContainer, onPrimary = LightSurfaceContainer,
primaryContainer = Purple80Container, primaryContainer = Purple80Container,
onPrimaryContainer = DarkSurfaceContainer, onPrimaryContainer = DarkSurfaceContainer,
secondary = PurpleGrey80, secondary = PurpleGrey80,

View File

@@ -7,18 +7,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import com.wismna.geoffroy.donext.domain.model.AppDestination import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.usecase.EmptyRecycleBinUseCase
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(
getTaskListsUseCase: GetTaskListsUseCase, getTaskListsUseCase: GetTaskListsUseCase
private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase
) : ViewModel() { ) : ViewModel() {
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
@@ -64,10 +61,4 @@ class MainViewModel @Inject constructor(
} }
} ?: startDestination } ?: startDestination
} }
fun emptyRecycleBin() {
viewModelScope.launch {
emptyRecycleBinUseCase()
}
}
} }

View File

@@ -1,6 +1,7 @@
package com.wismna.geoffroy.donext.presentation.viewmodel package com.wismna.geoffroy.donext.presentation.viewmodel
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.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -26,11 +27,14 @@ class ManageListsViewModel @Inject constructor(
var taskLists by mutableStateOf<List<TaskList>>(emptyList()) var taskLists by mutableStateOf<List<TaskList>>(emptyList())
private set private set
var taskCount by mutableIntStateOf(0)
private set
init { init {
getTaskListsUseCase() getTaskListsUseCase()
.onEach { lists -> .onEach { lists ->
taskLists = lists taskLists = lists
taskCount = lists.size
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
@@ -45,9 +49,9 @@ class ManageListsViewModel @Inject constructor(
updateTaskListUseCase(taskList.id!!, taskList.name, taskList.order) updateTaskListUseCase(taskList.id!!, taskList.name, taskList.order)
} }
} }
fun deleteTaskList(taskId: Long) { fun deleteTaskList(taskListId: Long) {
viewModelScope.launch { viewModelScope.launch {
deleteTaskListUseCase(taskId) deleteTaskListUseCase(taskListId)
} }
} }

View File

@@ -5,7 +5,8 @@ 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.Task import com.wismna.geoffroy.donext.domain.model.TaskWithListName
import com.wismna.geoffroy.donext.domain.usecase.EmptyRecycleBinUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetDeletedTasksUseCase import com.wismna.geoffroy.donext.domain.usecase.GetDeletedTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.PermanentlyDeleteTaskUseCase import com.wismna.geoffroy.donext.domain.usecase.PermanentlyDeleteTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
@@ -19,10 +20,11 @@ import javax.inject.Inject
class RecycleBinViewModel @Inject constructor( class RecycleBinViewModel @Inject constructor(
private val getDeletedTasks: GetDeletedTasksUseCase, private val getDeletedTasks: GetDeletedTasksUseCase,
private val restoreTask: ToggleTaskDeletedUseCase, private val restoreTask: ToggleTaskDeletedUseCase,
private val permanentlyDeleteTask: PermanentlyDeleteTaskUseCase private val permanentlyDeleteTask: PermanentlyDeleteTaskUseCase,
private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase
) : ViewModel() { ) : ViewModel() {
var deletedTasks by mutableStateOf<List<Task>>(emptyList()) var deletedTasks by mutableStateOf<List<TaskWithListName>>(emptyList())
private set private set
init { init {
@@ -50,4 +52,9 @@ class RecycleBinViewModel @Inject constructor(
loadDeletedTasks() loadDeletedTasks()
} }
} }
} fun emptyRecycleBin() {
viewModelScope.launch {
emptyRecycleBinUseCase()
}
}
}

View File

@@ -1,8 +1,8 @@
package com.wismna.geoffroy.donext.presentation.viewmodel package com.wismna.geoffroy.donext.presentation.viewmodel
import com.wismna.geoffroy.donext.domain.extension.toLocalDate
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 java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -21,16 +21,14 @@ class TaskItemViewModel(task: Task) {
val today: LocalDate = LocalDate.now(ZoneId.systemDefault()) val today: LocalDate = LocalDate.now(ZoneId.systemDefault())
val isOverdue: Boolean = task.dueDate?.let { millis -> val isOverdue: Boolean = task.dueDate?.let { millis ->
val dueDate = Instant.ofEpochMilli(millis) val dueDate = millis.toLocalDate()
.atZone(ZoneId.systemDefault())
.toLocalDate()
dueDate.isBefore(today) dueDate.isBefore(today)
} ?: false } ?: false
val dueDateText: String? = task.dueDate?.let { formatDueDate(it) } val dueDateText: String? = task.dueDate?.let { formatDueDate(it) }
private fun formatDueDate(dueMillis: Long): String { private fun formatDueDate(dueMillis: Long): String {
val dueDate = Instant.ofEpochMilli(dueMillis).atZone(ZoneId.systemDefault()).toLocalDate() val dueDate = dueMillis.toLocalDate()
return when { return when {
dueDate.isEqual(today) -> "Today" dueDate.isEqual(today) -> "Today"

View File

@@ -8,17 +8,18 @@ import androidx.lifecycle.viewModelScope
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.domain.usecase.AddTaskUseCase import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskUseCase import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TaskViewModel @Inject constructor( class TaskViewModel @Inject constructor(
private val createTaskUseCase: AddTaskUseCase, private val createTaskUseCase: AddTaskUseCase,
private val updateTaskUseCase: UpdateTaskUseCase, private val updateTaskUseCase: UpdateTaskUseCase
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase
) : ViewModel() { ) : ViewModel() {
var title by mutableStateOf("") var title by mutableStateOf("")
@@ -31,10 +32,13 @@ class TaskViewModel @Inject constructor(
private set private set
var isDone by mutableStateOf(false) var isDone by mutableStateOf(false)
private set private set
var isDeleted by mutableStateOf(false)
private set
private var editingTaskId: Long? = null private var editingTaskId: Long? = null
private var taskListId: Long? = null private var taskListId: Long? = null
fun screenTitle(): String = if (isDeleted) "Task details" else if (isEditing()) "Edit Task" else "New Task"
fun isEditing(): Boolean = editingTaskId != null fun isEditing(): Boolean = editingTaskId != null
fun startNewTask(selectedListId: Long) { fun startNewTask(selectedListId: Long) {
@@ -44,6 +48,7 @@ class TaskViewModel @Inject constructor(
description = "" description = ""
priority = Priority.NORMAL priority = Priority.NORMAL
dueDate = null dueDate = null
isDeleted = false
} }
fun startEditTask(task: Task) { fun startEditTask(task: Task) {
@@ -54,14 +59,23 @@ class TaskViewModel @Inject constructor(
priority = task.priority priority = task.priority
dueDate = task.dueDate dueDate = task.dueDate
isDone = task.isDone isDone = task.isDone
isDeleted = task.isDeleted
} }
fun onTitleChanged(value: String) { title = value } fun onTitleChanged(value: String) { title = value }
fun onDescriptionChanged(value: String) { description = value } fun onDescriptionChanged(value: String) { description = value }
fun onPriorityChanged(value: Priority) { priority = value } fun onPriorityChanged(value: Priority) { priority = value }
fun onDueDateChanged(value: Long?) { dueDate = value } fun onDueDateChanged(value: Long?) {
dueDate = if (value == null) null else
Instant.ofEpochMilli(value)
.atZone(ZoneOffset.UTC)
.toLocalDate()
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}
fun save(onDone: (() -> Unit)? = null) { fun save(onDone: (() -> Unit)? = null) {
if (title.isBlank()) return if (title.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
@@ -76,15 +90,6 @@ class TaskViewModel @Inject constructor(
} }
} }
fun delete() {
editingTaskId?.let { id ->
viewModelScope.launch {
toggleTaskDeletedUseCase(id, true)
reset()
}
}
}
fun reset() { fun reset() {
editingTaskId = null editingTaskId = null
taskListId = null taskListId = null

View File

@@ -18,4 +18,5 @@
# org.gradle.parallel=true # org.gradle.parallel=true
android.enableJetifier=false android.enableJetifier=false
android.useAndroidX=true android.useAndroidX=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx4g