Compare commits

...

17 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
Geoffroy Bonneville
b71fa4fdb7 Add an Empty Recycle Bin action button
Refactor Task Item Screen to include the Card
WIP on fix overdue dates calculation on task items
2025-09-24 21:29:23 -04:00
Geoffroy Bonneville
cf770ddb83 Fix some toasts
Change some icons
Fix warnings
2025-09-24 16:24:38 -04:00
Geoffroy Bonneville
ba2e259c7c Remove TODO 2025-09-24 16:09:50 -04:00
Geoffroy Bonneville
208f8bab3a Add the Due Today screen 2025-09-24 16:09:24 -04:00
Geoffroy Bonneville
2d4be63d81 Implement Recycle Bin
Improve task items UI
2025-09-24 12:33:11 -04:00
Geoffroy Bonneville
1c28d9aacb Added swiping moves to the tasks: left for done, right for delete
Rename some DAO functions
2025-09-19 19:17:44 -04:00
Geoffroy Bonneville
336755666b Tasks lists are now re-orderable
Edit task bottom sheet displays proper header
2025-09-18 18:54:53 -04:00
Geoffroy Bonneville
2be67abffa Fix navigation (once and for all ?)
Implement inline edit lists feature
Improve task list bottom sheet design
2025-09-18 15:40:19 -04:00
Geoffroy Bonneville
f8fd041f8e Refactor MainScreen and MainViewModel
Add list button now open bottom sheet
Add list bottom sheet works but design is WIP
WIP on inline list editing
2025-09-17 18:18:14 -04:00
Geoffroy Bonneville
0c5bf77b4d Remove unused imports 2025-09-17 15:15:15 -04:00
Geoffroy Bonneville
78ce584900 Theme is now working properly
Fix overdue use case
Allow selecting today as due date
Dates are now real badges
Animate done tasks
2025-09-17 15:14:33 -04:00
42 changed files with 1580 additions and 442 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"
@@ -46,26 +49,28 @@ android {
} }
dependencies { dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3") 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")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.00")) implementation("sh.calvin.reorderable:reorderable:3.0.0")
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

@@ -87,4 +87,75 @@ class TaskDaoTest {
TestCase.assertEquals(1, lists.first().first().overdueCount) TestCase.assertEquals(1, lists.first().first().overdueCount)
} }
@Test
fun dueToday_correctlyCalculated() = runBlocking {
listDao.insertTaskList(TaskListEntity(name = "Tasks", order = 0))
val listId = listDao.getTaskLists().first().first().id
val todayStart = Instant.parse("2025-09-15T00:00:00Z").toEpochMilli()
val todayEnd = Instant.parse("2025-09-15T23:59:99Z").toEpochMilli()
// One task due yesterday
taskDao.insertTask(
TaskEntity(
name = "Yesterday",
taskListId = listId,
dueDate = Instant.parse("2025-09-14T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One task due today
taskDao.insertTask(
TaskEntity(
name = "Today",
taskListId = listId,
dueDate = Instant.parse("2025-09-15T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One task due in the future
taskDao.insertTask(
TaskEntity(
name = "Tomorrow",
taskListId = listId,
dueDate = Instant.parse("2025-09-16T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One task due in the future
taskDao.insertTask(
TaskEntity(
name = "TodayDone",
taskListId = listId,
dueDate = Instant.parse("2025-09-15T12:00:00Z").toEpochMilli(),
isDone = true,
description = null,
priority = Priority.NORMAL
)
)
// One task due in the future
taskDao.insertTask(
TaskEntity(
name = "TodayDeleted",
taskListId = listId,
dueDate = Instant.parse("2025-09-15T12:00:00Z").toEpochMilli(),
isDone = false,
isDeleted = true,
description = null,
priority = Priority.NORMAL
)
)
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd)
TestCase.assertEquals(1, tasks.first().count())
TestCase.assertEquals("Prepare slides", tasks.first().first().name)
}
} }

View File

@@ -7,7 +7,7 @@
<activity <activity
android:name="com.wismna.geoffroy.donext.presentation.MainActivity" android:name="com.wismna.geoffroy.donext.presentation.MainActivity"
android:exported="true" android:exported="true"
android:label="@string/title_activity_main" android:label="DoNext"
android:theme="@style/Theme.DoNext"> android:theme="@style/Theme.DoNext">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

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
@@ -13,6 +14,25 @@ interface TaskDao {
@Query("SELECT * FROM tasks WHERE task_list_id = :listId AND deleted = 0 ORDER BY done ASC, priority DESC") @Query("SELECT * FROM tasks WHERE task_list_id = :listId AND deleted = 0 ORDER BY done ASC, priority DESC")
fun getTasksForList(listId: Long): Flow<List<TaskEntity>> fun getTasksForList(listId: Long): Flow<List<TaskEntity>>
@Query("""
SELECT * FROM tasks
WHERE due_date BETWEEN :todayStart AND :todayEnd AND deleted = 0 AND done = 0
ORDER BY done ASC, priority DESC
""")
fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<TaskEntity>>
@Query("""
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)
@@ -20,11 +40,17 @@ interface TaskDao {
suspend fun updateTask(task: TaskEntity) suspend fun updateTask(task: TaskEntity)
@Query("UPDATE tasks SET done = :done WHERE id = :taskId") @Query("UPDATE tasks SET done = :done WHERE id = :taskId")
suspend fun markTaskDone(taskId: Long, done: Boolean) suspend fun toggleTaskDone(taskId: Long, done: Boolean)
@Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskId") @Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskId")
suspend fun markTaskDeleted(taskId: Long, deleted: Boolean) suspend fun toggleTaskDeleted(taskId: Long, deleted: Boolean)
@Query("UPDATE tasks SET deleted = :deleted WHERE task_list_id = :taskListId") @Query("UPDATE tasks SET deleted = :deleted WHERE task_list_id = :taskListId")
suspend fun deleteAllTasksFromList(taskListId: Long, deleted: Boolean) suspend fun toggleAllTasksFromListDeleted(taskListId: Long, deleted: Boolean)
@Query("DELETE FROM tasks WHERE id = :taskId")
suspend fun permanentDeleteTask(taskId: Long)
@Query("DELETE FROM tasks WHERE deleted = 1")
suspend fun permanentDeleteAllDeletedTasks()
} }

View File

@@ -31,9 +31,13 @@ interface TaskListDao {
LEFT JOIN tasks t ON t.task_list_id = tl.id LEFT JOIN tasks t ON t.task_list_id = tl.id
WHERE tl.deleted = 0 WHERE tl.deleted = 0
GROUP BY tl.id GROUP BY tl.id
ORDER BY tl.display_order ASC
""") """)
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,10 +7,12 @@ 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
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.map
class TaskRepositoryImpl @Inject constructor( class TaskRepositoryImpl @Inject constructor(
private val taskDao: TaskDao, private val taskDao: TaskDao,
@@ -20,6 +22,18 @@ class TaskRepositoryImpl @Inject constructor(
return taskDao.getTasksForList(listId).map {entity -> entity.map { it.toDomain() }} return taskDao.getTasksForList(listId).map {entity -> entity.map { it.toDomain() }}
} }
override fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<Task>> {
return taskDao.getDueTodayTasks(todayStart, todayEnd).map {entity -> entity.map { it.toDomain() }}
}
override fun getDeletedTasks(): Flow<List<TaskWithListName>> {
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) {
taskDao.insertTask(task.toEntity()) taskDao.insertTask(task.toEntity())
} }
@@ -28,24 +42,40 @@ class TaskRepositoryImpl @Inject constructor(
taskDao.updateTask(task.toEntity()) taskDao.updateTask(task.toEntity())
} }
override suspend fun deleteTask(taskId: Long, isDeleted: Boolean) { override suspend fun toggleTaskDeleted(taskId: Long, isDeleted: Boolean) {
taskDao.markTaskDeleted(taskId, isDeleted) taskDao.toggleTaskDeleted(taskId, isDeleted)
} }
override suspend fun toggleTaskDone(taskId: Long, isDone: Boolean) { override suspend fun toggleTaskDone(taskId: Long, isDone: Boolean) {
taskDao.markTaskDone(taskId, isDone) taskDao.toggleTaskDone(taskId, isDone)
}
override suspend fun permanentlyDeleteTask(taskId: Long) {
taskDao.permanentDeleteTask(taskId)
}
override suspend fun permanentlyDeleteAllDeletedTask() {
taskDao.permanentDeleteAllDeletedTasks()
} }
override fun getTaskLists(): Flow<List<TaskList>> { override fun getTaskLists(): Flow<List<TaskList>> {
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())
} }
override suspend fun updateTaskList(taskList: TaskList) {
taskListDao.updateTaskList(taskList.toEntity())
}
override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) { override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) {
taskDao.deleteAllTasksFromList(taskListId, isDeleted) taskDao.toggleAllTasksFromListDeleted(taskListId, isDeleted)
taskListDao.deleteTaskList(taskListId, isDeleted) taskListDao.deleteTaskList(taskListId, isDeleted)
} }

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,28 @@
package com.wismna.geoffroy.donext.domain.model
sealed class AppDestination(
val route: String,
val title: String,
val showBackButton: Boolean = false,
) {
data class TaskList(val taskListId: Long, val name: String) : AppDestination(
route = "taskList/$taskListId",
title = name,
)
object DueTodayList : AppDestination(
route = "todayList",
title = "Due Today",
showBackButton = true,
)
object ManageLists : AppDestination(
route = "manageLists",
title = "Manage Lists",
showBackButton = true,
)
object RecycleBin : AppDestination(
route = "recycleBin",
title = "Recycle Bin",
showBackButton = true,
)
}

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,17 +3,25 @@ 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 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 deleteTask(taskId: Long, isDeleted: Boolean) suspend fun toggleTaskDeleted(taskId: Long, isDeleted: Boolean)
suspend fun toggleTaskDone(taskId: Long, isDone: Boolean) suspend fun toggleTaskDone(taskId: Long, isDone: Boolean)
suspend fun permanentlyDeleteTask(taskId: Long)
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 deleteTaskList(taskListId: Long, isDeleted: Boolean) suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
} }

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject
class GetDueTodayTasksUseCase @Inject constructor(private val repository: TaskRepository) {
operator fun invoke(): Flow<List<Task>> {
val todayStart = LocalDate.now()
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
val todayEnd = LocalDate.now()
.plusDays(1)
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli() - 1
return repository.getDueTodayTasks(
todayStart, todayEnd
)
}
}

View File

@@ -3,13 +3,17 @@ package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
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 java.time.Instant import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject import javax.inject.Inject
class GetTaskListsWithOverdueUseCase @Inject constructor( class GetTaskListsWithOverdueUseCase @Inject constructor(private val taskRepository: TaskRepository) {
private val taskRepository: TaskRepository
) {
operator fun invoke(): Flow<List<TaskListWithOverdue>> { operator fun invoke(): Flow<List<TaskListWithOverdue>> {
return taskRepository.getTaskListsWithOverdue(Instant.parse("2025-09-15T12:00:00Z").toEpochMilli()) return taskRepository.getTaskListsWithOverdue(
LocalDate.now()
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
)
} }
} }

View File

@@ -3,10 +3,10 @@ package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskRepository import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject import javax.inject.Inject
class DeleteTaskUseCase @Inject constructor( class PermanentlyDeleteTaskUseCase @Inject constructor(
private val repository: TaskRepository private val repository: TaskRepository
) { ) {
suspend operator fun invoke(taskId: Long) { suspend operator fun invoke(taskId: Long) {
repository.deleteTask(taskId, true) repository.permanentlyDeleteTask(taskId)
} }
} }

View File

@@ -0,0 +1,23 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class ToggleTaskDeletedUseCase @Inject constructor(
private val repository: TaskRepository
) {
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)
}
}

View File

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

View File

@@ -4,6 +4,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
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 dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -14,7 +15,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
DoNextTheme { MainScreen() } DoNextTheme(darkTheme = isSystemInDarkTheme(), dynamicColor = false) { MainScreen() }
} }
} }
} }

View File

@@ -0,0 +1,59 @@
package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.DueTodayViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun DueTodayTasksScreen(
modifier: Modifier = Modifier,
viewModel: DueTodayViewModel = hiltViewModel(),
onTaskClick: (task: Task) -> Unit
) {
val tasks = viewModel.dueTodayTasks
if (tasks.isEmpty()) {
// Placeholder when recycle bin is empty
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Nothing due today !")
}
} else {
val context = LocalContext.current
LazyColumn(
modifier = modifier.padding(8.dp)
) {
items(tasks, key = { it.id!! }) { task ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!)
Toast.makeText(context, "Task done", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteTask(task.id!!)
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT)
.show()
}
)
}
}
}
}

View File

@@ -11,9 +11,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -30,56 +32,35 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavBackStackEntry import androidx.navigation.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.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import com.wismna.geoffroy.donext.domain.model.AppDestination
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.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed class AppDestination(
val route: String,
val title: String,
val showBackButton: Boolean = false,
val actions: @Composable (() -> Unit)? = null
) {
data class TaskList(val taskListId: Long, val name: String) : AppDestination(
route = "taskList/$taskListId",
title = name,
)
object ManageLists : AppDestination(
route = "manageLists",
title = "Manage Lists",
showBackButton = true,
actions = { ManageListsActions() }
)
}
@Composable @Composable
fun MainScreen( fun MainScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MainViewModel = hiltViewModel() viewModel: MainViewModel = hiltViewModel()
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
var showBottomSheet by remember { mutableStateOf(false) }
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) {
@@ -89,38 +70,51 @@ fun MainScreen(
return return
} }
val firstListId = viewModel.taskLists.firstOrNull()?.id if (viewModel.showTaskSheet) {
if (showBottomSheet) { TaskBottomSheet(taskViewModel) { viewModel.showTaskSheet = false }
TaskBottomSheet(taskViewModel) { showBottomSheet = false } }
if (viewModel.showAddListSheet) {
AddListBottomSheet { viewModel.showAddListSheet = false }
} }
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = remember(navBackStackEntry, viewModel.taskLists) { viewModel.setCurrentDestination(navBackStackEntry)
deriveDestination(navBackStackEntry, viewModel.taskLists)
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerContent = { drawerContent = {
MenuScreen ( MenuScreen (
taskLists = viewModel.taskLists, currentDestination = viewModel.currentDestination,
currentDestination = currentDestination,
onNavigate = { route -> onNavigate = { route ->
scope.launch { drawerState.close() } scope.launch {
drawerState.close()
navController.navigate(route) { navController.navigate(route) {
//launchSingleTop = true
restoreState = true restoreState = true
} }
} }
}
) )
}, },
drawerState = drawerState drawerState = drawerState
) {
AppContent(viewModel = viewModel, taskViewModel = taskViewModel, navController = navController, scope = scope, drawerState = drawerState)
}
}
@Composable
fun AppContent(
modifier : Modifier = Modifier,
viewModel: MainViewModel,
taskViewModel: TaskViewModel,
navController: NavHostController,
scope: CoroutineScope,
drawerState: DrawerState
) { ) {
Scaffold( Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer), modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent, containerColor = Color.Transparent,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(currentDestination.title) }, title = { Text(viewModel.currentDestination.title) },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
@@ -128,9 +122,9 @@ fun MainScreen(
), ),
navigationIcon = { navigationIcon = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (currentDestination.showBackButton) { if (viewModel.currentDestination.showBackButton) {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
} }
} else { } else {
IconButton(onClick = { scope.launch { drawerState.open() } }) { IconButton(onClick = { scope.launch { drawerState.open() } }) {
@@ -142,15 +136,27 @@ fun MainScreen(
} }
} }
}, },
actions = { currentDestination.actions?.invoke() } actions = {
when (viewModel.currentDestination) {
is AppDestination.ManageLists -> {
IconButton(onClick = { viewModel.showAddListSheet = true }) {
Icon(Icons.Default.Add, contentDescription = "Add List")
}
}
is AppDestination.RecycleBin -> {
EmptyRecycleBinAction()
}
else -> null
}
}
) )
}, },
floatingActionButton = { floatingActionButton = {
when (val dest = currentDestination) { when (val dest = viewModel.currentDestination) {
is AppDestination.TaskList -> { is AppDestination.TaskList -> {
TaskListFab( TaskListFab(
taskListId = dest.taskListId, taskListId = dest.taskListId,
showBottomSheet = { showBottomSheet = it } showBottomSheet = { viewModel.showTaskSheet = it }
) )
} }
else -> null else -> null
@@ -166,8 +172,7 @@ fun MainScreen(
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = firstListId?.let { "taskList/$it" } startDestination = viewModel.startDestination.route,
?: AppDestination.ManageLists.route,
enterTransition = { enterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300)) slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
}, },
@@ -181,52 +186,48 @@ fun MainScreen(
slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300)) slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
} }
) { ) {
viewModel.taskLists.forEach { list ->
composable( composable(
route = "taskList/{taskListId}", route = "taskList/{taskListId}",
arguments = listOf(navArgument("taskListId") { arguments = listOf(navArgument("taskListId") {
type = NavType.LongType type = NavType.LongType
}) })
) { navBackStackEntry -> ) { navBackStackEntry ->
val viewModel: TaskListViewModel = hiltViewModel(navBackStackEntry) // TODO: when task list has been deleted, we should not navigate to it event if in the stack
val taskListViewModel: TaskListViewModel = hiltViewModel(navBackStackEntry)
TaskListScreen( TaskListScreen(
viewModel = viewModel, viewModel = taskListViewModel,
onTaskClick = { task -> onTaskClick = { task ->
taskViewModel.startEditTask(task) taskViewModel.startEditTask(task)
showBottomSheet = true viewModel.showTaskSheet = true
}
)
}
composable(AppDestination.ManageLists.route) {
ManageListsScreen(
modifier = Modifier,
showAddListSheet = {viewModel.showAddListSheet = true}
)
}
composable(AppDestination.DueTodayList.route) {
DueTodayTasksScreen (
modifier = Modifier,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
)
}
composable(AppDestination.RecycleBin.route) {
RecycleBinScreen(
modifier = Modifier,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
} }
) )
} }
} }
composable(AppDestination.ManageLists.route) {
ManageListsScreen(modifier = Modifier)
}
}
}
}
}
}
fun deriveDestination(
navBackStackEntry: NavBackStackEntry?,
taskLists: List<TaskListWithOverdue>
): AppDestination {
val route = navBackStackEntry?.destination?.route
return when {
route == AppDestination.ManageLists.route -> AppDestination.ManageLists
route?.startsWith("taskList/") == true || route == "taskList/{taskListId}" -> {
val idArg = navBackStackEntry.arguments?.getLong("taskListId")
val taskListId = idArg ?: route.substringAfter("taskList/", "").toLongOrNull()
val matching = taskLists.find { it.id == taskListId }
matching?.let { AppDestination.TaskList(it.id, it.name) }
?: taskLists.firstOrNull()?.let { AppDestination.TaskList(it.id, it.name) }
?: AppDestination.ManageLists
}
else -> {
taskLists.firstOrNull()?.let { AppDestination.TaskList(it.id, it.name) }
?: AppDestination.ManageLists
} }
} }
} }

View File

@@ -1,63 +1,269 @@
package com.wismna.geoffroy.donext.presentation.screen package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.Check
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.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.text.style.TextOverflow
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.presentation.viewmodel.ManageListsViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ManageListsScreen( fun ManageListsScreen(
modifier: Modifier, modifier: Modifier,
viewModel: ManageListsViewModel = hiltViewModel() viewModel: ManageListsViewModel = hiltViewModel(),
showAddListSheet: () -> Unit
) { ) {
val lists = viewModel.taskLists var lists = viewModel.taskLists.toMutableList()
val lazyListState = rememberLazyListState()
LazyColumn(modifier = modifier.fillMaxWidth().padding()) { val reorderState = rememberReorderableLazyListState(
itemsIndexed(lists, key = { _, list -> list.id!! }) { index, list -> lazyListState = lazyListState,
ListItem( onMove = { from, to ->
headlineContent = { Text(list.name) }, viewModel.moveTaskList(from.index, to.index)
trailingContent = {
Row {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
IconButton(onClick = { /* TODO: edit list */ }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
IconButton(onClick = { viewModel.deleteTaskList(list.id!!) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
} }
) )
HorizontalDivider()
LazyColumn(
modifier = modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
state = lazyListState
) {
itemsIndexed(lists, key = { _, list -> list.id!! }) { index, list ->
var isInEditMode by remember { mutableStateOf(false) }
var editedName by remember { mutableStateOf(list.name) }
ReorderableItem(
state = reorderState,
key = list.id!!
) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 5.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = Modifier.draggableHandle(
onDragStopped = {
viewModel.commitTaskListOrder()
},
interactionSource = interactionSource,
)
.clearAndSetSemantics {
customActions = listOf(
CustomAccessibilityAction(
label = "Move Up",
action = {
if (index > 0) {
lists = lists.toMutableList().apply {
add(index - 1, removeAt(index))
}
true
} else {
false
}
}
),
CustomAccessibilityAction(
label = "Move Down",
action = {
if (index < lists.size - 1) {
lists = lists.toMutableList().apply {
add(index + 1, removeAt(index))
}
true
} else {
false
}
}
),
)
},
interactionSource = interactionSource,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.DragHandle, contentDescription = "Edit")
AnimatedContent(
targetState = isInEditMode,
modifier = Modifier.weight(1f),
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "Headline transition"
) { isEditing ->
if (isEditing) {
OutlinedTextField(
value = editedName,
onValueChange = { editedName = it },
singleLine = true
)
} else {
Text(
modifier = Modifier.padding(start = 8.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = list.name
)
}
}
AnimatedContent(
targetState = isInEditMode,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "Trailing transition"
) { editing ->
if (editing) {
Row {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
IconButton(onClick = { isInEditMode = false }) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
IconButton(onClick = {
viewModel.updateTaskListName(list.copy(name = editedName))
isInEditMode = false
}) {
Icon(Icons.Default.Check, contentDescription = "Save")
}
}
}
} else {
Row {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
IconButton(onClick = { isInEditMode = true }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
IconButton(onClick = { viewModel.deleteTaskList(list.id) }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete"
)
}
}
}
}
}
}
}
}
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ManageListsActions( fun AddListBottomSheet(
viewModel: ManageListsViewModel = hiltViewModel() viewModel: ManageListsViewModel = hiltViewModel(),
onDismiss: () -> Unit
) { ) {
IconButton(onClick = { viewModel.createTaskList("Test", 1) }) { val titleFocusRequester = remember { FocusRequester() }
Icon(Icons.Default.Add, contentDescription = "Add List")
LaunchedEffect(Unit) {
titleFocusRequester.requestFocus()
}
ModalBottomSheet(onDismissRequest = onDismiss) {
var name by remember { mutableStateOf("") }
//var type by remember { mutableStateOf(ListType.Default) }
//var description by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
Text("New List", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
/*TextField(
value = name,
onValueChange = { name = it },
label = { Text("List Name") },
singleLine = true
)*/
OutlinedTextField(
value = name,
singleLine = true,
onValueChange = { name = it },
label = { Text("Title") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester)
)
Spacer(Modifier.height(8.dp))
//DropdownSelector(selected = type, onSelect = { type = it })
/*Spacer(Modifier.height(8.dp))
TextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
maxLines = 3
)*/
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
//TextButton(onClick = onDismiss) { Text("Cancel") }
//Spacer(Modifier.width(8.dp))
Button(
onClick = {
viewModel.createTaskList(name/*, type, description*/, viewModel.taskCount + 1)
onDismiss()
},
enabled = name.isNotBlank()
) {
Text("Create")
}
}
}
} }
} }

View File

@@ -2,11 +2,15 @@ package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
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.filled.Edit import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.List 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
@@ -17,12 +21,15 @@ import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.presentation.viewmodel.MenuViewModel
@Composable @Composable
fun MenuScreen( fun MenuScreen(
taskLists: List<TaskListWithOverdue>, viewModel: MenuViewModel = hiltViewModel(),
currentDestination: AppDestination, currentDestination: AppDestination,
onNavigate: (String) -> Unit onNavigate: (String) -> Unit
) { ) {
@@ -42,10 +49,29 @@ fun MenuScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
taskLists.forEach { list ->
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(list.name) }, label = {
icon = { Icon(Icons.Default.List, contentDescription = list.name) }, Row (modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Due Today")
Text(viewModel.dueTodayTasksCount.toString())
}
},
icon = { Icon(Icons.Default.Today, contentDescription = "Due Today") },
selected = currentDestination is AppDestination.DueTodayList,
onClick = { onNavigate(AppDestination.DueTodayList.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
HorizontalDivider()
viewModel.taskLists.forEach { list ->
NavigationDrawerItem(
label = {
Text(
text = list.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
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}") },
@@ -61,9 +87,16 @@ fun MenuScreen(
Column { Column {
HorizontalDivider() HorizontalDivider()
NavigationDrawerItem(
label = { Text("Recycle Bin") },
icon = { Icon(Icons.Default.Delete, contentDescription = "Recycle Bin") },
selected = currentDestination is AppDestination.RecycleBin,
onClick = { onNavigate(AppDestination.RecycleBin.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
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

@@ -0,0 +1,139 @@
package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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.TextButton
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.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.RecycleBinViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun RecycleBinScreen(
modifier: Modifier = Modifier,
viewModel: RecycleBinViewModel = hiltViewModel(),
onTaskClick: (task: Task) -> Unit
) {
val tasks = viewModel.deletedTasks
if (tasks.isEmpty()) {
// Placeholder when recycle bin is empty
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Recycle Bin is empty")
}
return
}
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(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(item.task),
onTaskClick = { onTaskClick(item.task) },
onSwipeLeft = {
viewModel.restore(item.task.id!!)
Toast.makeText(context, "Task restored", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteForever(item.task.id!!)
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

@@ -1,34 +1,42 @@
package com.wismna.geoffroy.donext.presentation.screen package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons
import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material3.Checkbox import androidx.compose.material.icons.filled.RestoreFromTrash
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Unpublished
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
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
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@@ -36,9 +44,23 @@ import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
fun TaskItemScreen( fun TaskItemScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: TaskItemViewModel, viewModel: TaskItemViewModel,
onClick: () -> Unit, onTaskClick: (taskId: Long) -> Unit,
onToggleDone: (Boolean) -> Unit onSwipeLeft: () -> Unit,
onSwipeRight: () -> Unit
) { ) {
// TODO: change this
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
when (it) {
SwipeToDismissBoxValue.StartToEnd -> { onSwipeRight() }
SwipeToDismissBoxValue.EndToStart -> { onSwipeLeft() }
SwipeToDismissBoxValue.Settled -> return@rememberSwipeToDismissBoxState false
}
return@rememberSwipeToDismissBoxState true
},
// positional threshold of 25%
positionalThreshold = { it * .25f }
)
val baseStyle = MaterialTheme.typography.bodyLarge.copy( val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (viewModel.priority) { fontWeight = when (viewModel.priority) {
Priority.HIGH -> FontWeight.Bold Priority.HIGH -> FontWeight.Bold
@@ -50,25 +72,33 @@ fun TaskItemScreen(
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
}, },
textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None) textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None
)
Card(
modifier = modifier,
onClick = { onTaskClick(viewModel.id) },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
DismissBackground(
dismissState,
viewModel.isDone,
viewModel.isDeleted
)
},
content = {
Row( Row(
modifier = modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .background(MaterialTheme.colorScheme.surfaceContainer)
.padding(8.dp) .padding(8.dp)
.alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f), .alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f),
verticalAlignment = Alignment.CenterVertically // centers checkbox + content verticalAlignment = Alignment.CenterVertically // centers checkbox + content
) { ) {
// Done checkbox
Checkbox(
checked = viewModel.isDone,
onCheckedChange = onToggleDone,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
)
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -78,55 +108,103 @@ fun TaskItemScreen(
// Title // Title
Text( Text(
text = viewModel.name, text = viewModel.name,
fontSize = 18.sp,
style = baseStyle, style = baseStyle,
modifier = Modifier modifier = Modifier
.align( .align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterStart if (viewModel.description.isNullOrBlank()) Alignment.CenterStart
else Alignment.TopStart else Alignment.TopStart
) ),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
) )
// Due date badge // Due date badge
viewModel.dueDateText?.let { dueMillis -> viewModel.dueDateText?.let { dueMillis ->
Box( Badge(
modifier = Modifier modifier = Modifier
.align( .align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterEnd if (viewModel.description.isNullOrBlank()) Alignment.CenterEnd
else Alignment.TopEnd else Alignment.TopEnd
) ),
.background( containerColor = if (viewModel.isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer
color = if (viewModel.isOverdue) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 8.dp, vertical = 2.dp)
) { ) {
Text( Text(
modifier = Modifier.padding(start = 1.dp, end = 1.dp),
text = viewModel.dueDateText, text = viewModel.dueDateText,
style = MaterialTheme.typography.bodySmall, color = if (viewModel.isOverdue) Color.White else MaterialTheme.colorScheme.onPrimaryContainer,
color = if (viewModel.isOverdue) Color.White else MaterialTheme.colorScheme.primary style = MaterialTheme.typography.bodySmall
) )
} }
} }
// Optional description // Optional description
this@Row.AnimatedVisibility( Box(
visible = !viewModel.description.isNullOrBlank(),
modifier = Modifier.align(Alignment.BottomStart),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Text(
text = viewModel.description!!,
style = baseStyle.copy(fontSize = MaterialTheme.typography.bodyMedium.fontSize),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .fillMaxWidth()
.padding(top = 20.dp) // spacing below title .height(40.dp)
.padding(top = 24.dp),
contentAlignment = Alignment.TopStart
) {
if (!viewModel.description.isNullOrBlank()) {
Text(
text = viewModel.description,
color = MaterialTheme.colorScheme.tertiary,
style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
),
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
} }
}
}
}
})
}
}
@Composable
fun DismissBackground(dismissState: SwipeToDismissBoxState, isDone: Boolean, isDeleted: Boolean) {
val color = when (dismissState.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.error
SwipeToDismissBoxValue.EndToStart -> Color(0xFF18590D)
SwipeToDismissBoxValue.Settled -> Color.Transparent
}
Row(
modifier = Modifier
.fillMaxSize()
.background(color)
.padding(12.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column (horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
if (isDeleted) Icons.Default.DeleteForever else Icons.Default.DeleteOutline,
tint = Color.LightGray,
contentDescription = "Delete"
)
Text(
color = MaterialTheme.colorScheme.onPrimary,
fontSize = 10.sp,
text = if (isDeleted) "Delete" else "Recycle"
)
}
Spacer(modifier = Modifier)
Column (horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
if (isDeleted) Icons.Default.RestoreFromTrash else
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

@@ -1,20 +1,23 @@
package com.wismna.geoffroy.donext.presentation.screen package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Spacer import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height 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.itemsIndexed import androidx.compose.foundation.lazy.items
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.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
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.platform.LocalContext
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
@@ -29,39 +32,68 @@ fun TaskListScreen(
onTaskClick: (Task) -> Unit) { onTaskClick: (Task) -> Unit) {
val tasks = viewModel.tasks val tasks = viewModel.tasks
// Split tasks into active and done
val (active, done) = remember(tasks) {
tasks.partition { !it.isDone }
}
val context = LocalContext.current
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize().padding() modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
itemsIndexed(tasks, key = { id, task -> task.id!! }) { index, task -> // Active tasks section
if (index > 0) { items(
val prev = tasks[index - 1] items = active,
key = { it.id!! }
when { ) { task ->
// Divider between non-done and done tasks
!prev.isDone && task.isDone -> {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
modifier = Modifier.padding(vertical = 8.dp)
)
}
// Extra spacing between different priorities (only if done status is same)
prev.priority != task.priority && prev.isDone == task.isDone -> {
Spacer(modifier = Modifier.height(20.dp))
}
}
}
TaskItemScreen( TaskItemScreen(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task), viewModel = TaskItemViewModel(task),
onClick = { onTaskClick(task) }, onTaskClick = { onTaskClick(task) },
onToggleDone = { isChecked -> onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, isChecked) viewModel.updateTaskDone(task.id!!, true)
Toast.makeText(context, "Task done", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteTask(task.id!!)
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT).show()
} }
) )
} }
// Divider between active and done (optional)
if (done.isNotEmpty() && active.isNotEmpty()) {
item {
HorizontalDivider(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
)
}
}
// Done tasks section
items(
items = done,
key = { it.id!! }
) { task ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, false)
Toast.makeText(context, "Task in progress", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteTask(task.id!!)
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT).show()
},
)
}
} }
} }

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(
"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(ZoneOffset.UTC) ?: ""
.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() val todayStartUtcMillis = LocalDate.now(ZoneId.systemDefault())
.atStartOfDay(ZoneId.systemDefault()) .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,28 +172,36 @@ fun TaskBottomSheet(
DatePicker(state = datePickerState) DatePicker(state = datePickerState)
} }
} }
if (!viewModel.isDeleted) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row ( Row (
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (viewModel.isEditing()) Arrangement.SpaceBetween else Arrangement.End) { horizontalArrangement = Arrangement.SpaceBetween
// --- Delete Button --- ) {
if (viewModel.isEditing()) { // --- Cancel Button ---
Button( Button(
onClick = { viewModel.delete(); onDismiss() }, onClick = {
colors = ButtonDefaults.textButtonColors( scope.launch {
contentColor = MaterialTheme.colorScheme.error sheetState.hide()
) onDismiss()
) { Text("Delete") }
} }
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
) { Text("Cancel") }
// --- Save Button --- // --- Save Button ---
Button( Button(
onClick = { onClick = {
scope.launch {
viewModel.save() viewModel.save()
sheetState.hide()
onDismiss() onDismiss()
}
}, },
enabled = viewModel.title.isNotBlank() && !viewModel.isDone, enabled = viewModel.title.isNotBlank() && !viewModel.isDeleted,
) { ) {
Text(if (viewModel.isEditing()) "Save" else "Create") Text(if (viewModel.isEditing()) "Save" else "Create")
} }
@@ -196,10 +209,12 @@ 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)
@@ -210,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

@@ -2,10 +2,26 @@ package com.wismna.geoffroy.donext.presentation.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF) // Primary shades
val PurpleGrey80 = Color(0xFFCCC2DC) val Purple80 = Color(0xFF6A59C7) // Light theme primary
val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650A4) // Dark theme primary
val Purple40 = Color(0xFF6650a4) val PurpleGrey80 = Color(0xFFCCC2DC)
val PurpleGrey40 = Color(0xFF625b71) val PurpleGrey40 = Color(0xFF625B71)
val Pink80 = Color(0xFFEFB8C8)
val Pink40 = Color(0xFF7D5260) val Pink40 = Color(0xFF7D5260)
// Container variants
val Purple80Container = Color(0xFFEADDFF)
val Purple40Container = Color(0xFF381E72)
val PurpleGrey80Container = Color(0xFFE8DEF8)
val PurpleGrey40Container = Color(0xFF4A4458)
val Pink80Container = Color(0xFFFFD8E4)
val Pink40Container = Color(0xFF633B48)
// Surface container
val LightSurfaceContainer = Color(0xFFF5F3FF)
val DarkSurfaceContainer = Color(0xFF1E1B2C)

View File

@@ -8,34 +8,12 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable @Composable
fun DoNextTheme( fun DoNextTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
@@ -45,8 +23,51 @@ fun DoNextTheme(
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme darkTheme -> darkColorScheme(
else -> LightColorScheme primary = Purple40,
onPrimary = LightSurfaceContainer,
primaryContainer = Purple40Container,
onPrimaryContainer = LightSurfaceContainer,
secondary = PurpleGrey40,
onSecondary = LightSurfaceContainer,
secondaryContainer = PurpleGrey40Container,
onSecondaryContainer = LightSurfaceContainer,
tertiary = Pink80,
onTertiary = DarkSurfaceContainer,
tertiaryContainer = Pink40Container,
onTertiaryContainer = LightSurfaceContainer,
background = Color(0xFF121212),
onBackground = LightSurfaceContainer,
surface = Color(0xFF121212),
onSurface = LightSurfaceContainer,
surfaceVariant = DarkSurfaceContainer,
onSurfaceVariant = LightSurfaceContainer,
error = Color(0xFFCF6679),
onError = DarkSurfaceContainer
)
else -> lightColorScheme(
primary = Purple80,
onPrimary = LightSurfaceContainer,
primaryContainer = Purple80Container,
onPrimaryContainer = DarkSurfaceContainer,
secondary = PurpleGrey80,
onSecondary = DarkSurfaceContainer,
secondaryContainer = PurpleGrey80Container,
onSecondaryContainer = DarkSurfaceContainer,
tertiary = Pink40,
onTertiary = LightSurfaceContainer,
tertiaryContainer = Pink80Container,
onTertiaryContainer = DarkSurfaceContainer,
background = Color(0xFFFFFBFE),
onBackground = DarkSurfaceContainer,
surface = Color(0xFFFFFBFE),
onSurface = DarkSurfaceContainer,
surfaceVariant = LightSurfaceContainer,
onSurfaceVariant = DarkSurfaceContainer,
error = Color(0xFFB00020),
onError = LightSurfaceContainer
)
} }
MaterialTheme( MaterialTheme(

View File

@@ -0,0 +1,46 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DueTodayViewModel @Inject constructor(
getDueTodayTasks: GetDueTodayTasksUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase
) : ViewModel() {
var dueTodayTasks by mutableStateOf<List<Task>>(emptyList())
private set
init {
getDueTodayTasks()
.onEach { tasks ->
dueTodayTasks = tasks
}
.launchIn(viewModelScope)
}
fun updateTaskDone(taskId: Long) {
viewModelScope.launch {
toggleTaskDone(taskId, true)
}
}
fun deleteTask(taskId: Long) {
viewModelScope.launch {
toggleTaskDeleted(taskId, true)
}
}
}

View File

@@ -5,8 +5,9 @@ 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.TaskListWithOverdue import androidx.navigation.NavBackStackEntry
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase import com.wismna.geoffroy.donext.domain.model.AppDestination
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
@@ -14,23 +15,50 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase getTaskListsUseCase: GetTaskListsUseCase
) : ViewModel() { ) : ViewModel() {
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set
/*val destinations: List<AppDestination>
get() = taskLists.map { AppDestination.TaskList(it.id, it.name) } +
AppDestination.ManageLists*/
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
private set private set
var destinations by mutableStateOf<List<AppDestination>>(emptyList())
private set
var startDestination by mutableStateOf<AppDestination>(AppDestination.ManageLists)
private set
var currentDestination by mutableStateOf(startDestination)
private set
var showTaskSheet by mutableStateOf(false)
var showAddListSheet by mutableStateOf(false)
init { init {
getTaskListsWithOverdue() getTaskListsUseCase()
.onEach { lists -> .onEach { lists ->
taskLists = lists destinations = lists.map { taskList ->
AppDestination.TaskList(taskList.id!!, taskList.name)
} +
AppDestination.ManageLists +
AppDestination.RecycleBin +
AppDestination.DueTodayList
isLoading = false isLoading = false
if (startDestination == AppDestination.ManageLists && destinations.isNotEmpty()) {
startDestination = destinations.first()
}
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
fun setCurrentDestination(navBackStackEntry: NavBackStackEntry?) {
val route = navBackStackEntry?.destination?.route
val taskListId = navBackStackEntry?.arguments?.getLong("taskListId")
currentDestination = destinations.firstOrNull { dest ->
when (dest) {
is AppDestination.TaskList -> taskListId != null && dest.taskListId == taskListId
else -> dest.route == route
}
} ?: startDestination
}
} }

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
@@ -9,6 +10,7 @@ import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskListUseCase import com.wismna.geoffroy.donext.domain.usecase.AddTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskListUseCase
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
@@ -19,16 +21,20 @@ import javax.inject.Inject
class ManageListsViewModel @Inject constructor( class ManageListsViewModel @Inject constructor(
getTaskListsUseCase: GetTaskListsUseCase, getTaskListsUseCase: GetTaskListsUseCase,
private val addTaskListUseCase: AddTaskListUseCase, private val addTaskListUseCase: AddTaskListUseCase,
private val updateTaskListUseCase: UpdateTaskListUseCase,
private val deleteTaskListUseCase: DeleteTaskListUseCase private val deleteTaskListUseCase: DeleteTaskListUseCase
) : ViewModel() { ) : ViewModel() {
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)
} }
@@ -38,9 +44,31 @@ class ManageListsViewModel @Inject constructor(
addTaskListUseCase(title, order) addTaskListUseCase(title, order)
} }
} }
fun deleteTaskList(taskId: Long) { fun updateTaskListName(taskList: TaskList) {
viewModelScope.launch { viewModelScope.launch {
deleteTaskListUseCase(taskId) updateTaskListUseCase(taskList.id!!, taskList.name, taskList.order)
}
}
fun deleteTaskList(taskListId: Long) {
viewModelScope.launch {
deleteTaskListUseCase(taskListId)
}
}
fun moveTaskList(fromIndex: Int, toIndex: Int) {
val mutable = taskLists.toMutableList()
val item = mutable.removeAt(fromIndex)
mutable.add(toIndex, item)
taskLists = mutable
}
fun commitTaskListOrder() {
viewModelScope.launch {
taskLists.forEachIndexed { index, list ->
if (list.order != index) {
updateTaskListUseCase(list.id!!, list.name, index)
}
}
} }
} }
} }

View File

@@ -0,0 +1,41 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@HiltViewModel
class MenuViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase,
getDueTodayTasks: GetDueTodayTasksUseCase
) : ViewModel() {
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set
var dueTodayTasksCount by mutableIntStateOf(0)
private set
init {
getTaskListsWithOverdue()
.onEach { lists ->
taskLists = lists
}
.launchIn(viewModelScope)
getDueTodayTasks()
.onEach { tasks ->
dueTodayTasksCount = tasks.count()
}
.launchIn(viewModelScope)
}
}

View File

@@ -0,0 +1,60 @@
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.TaskWithListName
import com.wismna.geoffroy.donext.domain.usecase.EmptyRecycleBinUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetDeletedTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.PermanentlyDeleteTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class RecycleBinViewModel @Inject constructor(
private val getDeletedTasks: GetDeletedTasksUseCase,
private val restoreTask: ToggleTaskDeletedUseCase,
private val permanentlyDeleteTask: PermanentlyDeleteTaskUseCase,
private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase
) : ViewModel() {
var deletedTasks by mutableStateOf<List<TaskWithListName>>(emptyList())
private set
init {
loadDeletedTasks()
}
fun loadDeletedTasks() {
getDeletedTasks()
.onEach { tasks ->
deletedTasks = tasks
}
.launchIn(viewModelScope)
}
fun restore(taskId: Long) {
viewModelScope.launch {
restoreTask(taskId, false)
loadDeletedTasks()
}
}
fun deleteForever(taskId: Long) {
viewModelScope.launch {
permanentlyDeleteTask(taskId)
loadDeletedTasks()
}
}
fun emptyRecycleBin() {
viewModelScope.launch {
emptyRecycleBinUseCase()
}
}
}

View File

@@ -1,10 +1,10 @@
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.ZoneOffset import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle import java.time.format.TextStyle
@@ -15,21 +15,20 @@ class TaskItemViewModel(task: Task) {
val name: String = task.name val name: String = task.name
val description: String? = task.description val description: String? = task.description
val isDone: Boolean = task.isDone val isDone: Boolean = task.isDone
val isDeleted: Boolean = task.isDeleted
val priority: Priority = task.priority val priority: Priority = task.priority
val today: LocalDate = LocalDate.now(ZoneOffset.UTC) 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(ZoneOffset.UTC)
.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(ZoneOffset.UTC).toLocalDate() val dueDate = dueMillis.toLocalDate()
return when { return when {
dueDate.isEqual(today) -> "Today" dueDate.isEqual(today) -> "Today"

View File

@@ -7,8 +7,9 @@ import androidx.lifecycle.SavedStateHandle
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.Task
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
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
@@ -19,6 +20,7 @@ import javax.inject.Inject
class TaskListViewModel @Inject constructor( class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase, getTasks: GetTasksForListUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase, private val toggleTaskDone: ToggleTaskDoneUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@@ -43,4 +45,9 @@ class TaskListViewModel @Inject constructor(
toggleTaskDone(taskId, isDone) toggleTaskDone(taskId, isDone)
} }
} }
fun deleteTask(taskId: Long) {
viewModelScope.launch {
toggleTaskDeleted(taskId, true)
}
}
} }

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.DeleteTaskUseCase
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 deleteTaskUseCase: DeleteTaskUseCase
) : 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,12 +59,21 @@ 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
@@ -76,15 +90,6 @@ class TaskViewModel @Inject constructor(
} }
} }
fun delete() {
editingTaskId?.let { id ->
viewModelScope.launch {
deleteTaskUseCase(id)
reset()
}
}
}
fun reset() { fun reset() {
editingTaskId = null editingTaskId = null
taskListId = null taskListId = null

View File

@@ -19,3 +19,4 @@
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