Compare commits

...

25 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
Geoffroy Bonneville
926a9bf66b Add create task lists button (WIP)
Implement delete task list button
Set theme colors
Add animations
Refactor main screen
Refactor task item with a view model
Put Edit Lists at the bottom of the Navigation Drawer
2025-09-16 17:18:01 -04:00
Geoffroy Bonneville
1692a197f2 Added overdue task counter on task lists in drawer
Display due date in badge on the right of task
Added a unit test
Limit due dates to dates in the future
Fix due date migration
WIP Edit lists screen
WIP Refactor navhost with viewmodel
2025-09-15 20:40:24 -04:00
Geoffroy Bonneville
744d2afdc1 Replace tabs with navigation drawer
WIP on overdue task count on list names
2025-09-14 19:37:28 -04:00
Geoffroy Bonneville
83f441a618 Little padding on priority text 2025-09-12 22:38:51 -04:00
Geoffroy Bonneville
80f86ebdee Lower alpha on low priority tasks 2025-09-12 22:34:44 -04:00
Geoffroy Bonneville
e250ac91d0 Simplify due date data type
Due date displays proper date
Overdue tasks display as red
2025-09-12 22:32:39 -04:00
Geoffroy Bonneville
cc25aa4b05 Display the due date in the bottom sheet 2025-09-12 16:09:29 -04:00
Geoffroy Bonneville
7939257cd6 Tapping on a task allows edition
Refactoring of the bottom sheet to allow edition
Create a Task ViewModel
2025-09-12 14:12:55 -04:00
54 changed files with 2401 additions and 466 deletions

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="1337588336">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@@ -87,6 +87,19 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</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>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -366,18 +379,6 @@
<option name="screenX" value="384" />
<option name="screenY" value="384" />
</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>
<option name="api" value="33" />
<option name="brand" value="google" />
@@ -560,6 +561,18 @@
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</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>
<option name="api" value="34" />
<option name="brand" value="google" />

View File

@@ -8,6 +8,9 @@
<SelectionState runConfigName="donextv2">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="overdueCount_correctlyCalculated()">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

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)
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...
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:
Swipe it to the left: it's done!
Swipe it to the right: "nexted!". It goes to the end of the list and you can work on the next task.
Focus on what's important:
- Tasks are sorted by priority
- 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 {
release {
isMinifyEnabled = false
// Enables code-related app optimization.
isMinifyEnabled = true
// Enables resource shrinking.
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -46,25 +49,28 @@ android {
}
dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3")
implementation("androidx.activity:activity-compose:1.10.1")
implementation(platform("androidx.compose:compose-bom:2025.08.01"))
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
implementation("androidx.activity:activity-compose:1.11.0")
implementation(platform("androidx.compose:compose-bom:2025.09.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:2.9.4")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.08.01"))
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.test.ext:junit-ktx:1.3.0")
implementation("sh.calvin.reorderable:reorderable:3.0.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
val roomVersion = "2.8.0"
val roomVersion = "2.8.1"
implementation("androidx.room:room-runtime:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
val hiltVersion = "2.57.1"
val hiltVersion = "2.57.2"
implementation("com.google.dagger:hilt-android:$hiltVersion")
ksp("com.google.dagger:hilt-android-compiler:$hiltVersion")
}

View File

@@ -0,0 +1,161 @@
package com.wismna.geoffroy.donext.data.local.repository
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.AppDatabase
import com.wismna.geoffroy.donext.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.domain.model.Priority
import junit.framework.TestCase
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {
private lateinit var db: AppDatabase
private lateinit var taskDao: TaskDao
private lateinit var listDao: TaskListDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).allowMainThreadQueries().build()
taskDao = db.taskDao()
listDao = db.taskListDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun overdueCount_correctlyCalculated() = runBlocking {
listDao.insertTaskList(TaskListEntity(name = "Work", order = 0))
val listId = listDao.getTaskLists().first().first().id
val now = Instant.parse("2025-09-15T12:00:00Z").toEpochMilli()
// One overdue task (yesterday)
taskDao.insertTask(
TaskEntity(
name = "Finish report",
taskListId = listId,
dueDate = Instant.parse("2025-09-14T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One not overdue task (tomorrow)
taskDao.insertTask(
TaskEntity(
name = "Prepare slides",
taskListId = listId,
dueDate = Instant.parse("2025-09-16T12:00:00Z").toEpochMilli(),
isDone = false,
description = null,
priority = Priority.NORMAL
)
)
// One done task (yesterday, but marked done)
taskDao.insertTask(
TaskEntity(
name = "Old task",
taskListId = listId,
dueDate = Instant.parse("2025-09-14T12:00:00Z").toEpochMilli(),
isDone = true,
description = null,
priority = Priority.NORMAL
)
)
val lists = listDao.getTaskListsWithOverdue(now)
TestCase.assertEquals(1, lists.first().first().overdueCount)
}
@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
android:name="com.wismna.geoffroy.donext.presentation.MainActivity"
android:exported="true"
android:label="@string/title_activity_main"
android:label="DoNext"
android:theme="@style/Theme.DoNext">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

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

View File

@@ -2,19 +2,8 @@ package com.wismna.geoffroy.donext.data
import androidx.room.TypeConverter
import com.wismna.geoffroy.donext.domain.model.Priority
import java.time.Instant
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Instant? {
return value?.let { Instant.ofEpochMilli(it) }
}
@TypeConverter
fun instantToTimestamp(instant: Instant?): Long? {
return instant?.toEpochMilli()
}
@TypeConverter
fun fromPriority(priority: Priority): Int = priority.value

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.TaskListEntity
import com.wismna.geoffroy.donext.data.entities.TaskWithListNameEntity
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskWithListName
fun TaskEntity.toDomain() = Task(
id = id,
@@ -15,6 +17,12 @@ fun TaskEntity.toDomain() = Task(
dueDate = dueDate,
priority = priority,
)
fun TaskWithListNameEntity.toDomain(): TaskWithListName {
return TaskWithListName(
task = task.toDomain(),
listName = listName
)
}
fun Task.toEntity() = TaskEntity(
id = id ?: 0,
@@ -35,7 +43,7 @@ fun TaskListEntity.toDomain() = TaskList(
)
fun TaskList.toEntity() = TaskListEntity(
id = id,
id = id ?: 0,
name = name,
isDeleted = isDeleted,
order = order

View File

@@ -4,7 +4,6 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.wismna.geoffroy.donext.domain.model.Priority
import java.time.Instant
@Entity(tableName = "tasks")
data class TaskEntity(
@@ -20,5 +19,5 @@ data class TaskEntity(
@ColumnInfo(name = "task_list_id")
val taskListId: Long,
@ColumnInfo(name = "due_date")
val dueDate: Instant? = null
val dueDate: Long? = null
)

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

@@ -8,10 +8,10 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.wismna.geoffroy.donext.data.Converters
import com.wismna.geoffroy.donext.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -34,41 +34,44 @@ abstract class AppDatabase : RoomDatabase() {
db.beginTransaction()
try {
// --- TASKS TABLE ---
// 0. Convert old due date format
// Add temporary column
db.execSQL("ALTER TABLE tasks ADD COLUMN duedate_temp INTEGER")
// Populate temporary column
db.execSQL("""
UPDATE tasks
SET duedate_temp =
CASE
WHEN duedate IS NULL OR duedate = '' THEN NULL
ELSE (strftime('%s', duedate || 'T00:00:00Z') * 1000)
END
""".trimIndent())
// 1. Create the new tasks table with the updated schema
db.execSQL(
"""
CREATE TABLE tasks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
description TEXT,
priority INTEGER NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
task_list_id INTEGER NOT NULL,
due_date INTEGER
)
""".trimIndent()
CREATE TABLE tasks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
description TEXT,
priority INTEGER NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
task_list_id INTEGER NOT NULL,
due_date INTEGER
)
""".trimIndent()
)
// 2. Copy old data into the new table
// Map old column names to new ones
db.execSQL(
"""
INSERT INTO tasks_new (
id, name, description, priority,
done, deleted, task_list_id, due_date
)
SELECT
_id, -- old '_id' mapped to id
name,
description,
priority,
done,
deleted,
list, -- old 'list' mapped to task_list_id
duedate -- old column renamed to due_date
FROM tasks
""".trimIndent()
INSERT INTO tasks_new (id, name, description, priority,done, deleted, task_list_id, due_date)
SELECT _id, name, description, priority, done, deleted, list, duedate_temp
FROM tasks
""".trimIndent()
)
// 3. Drop the old table
@@ -80,27 +83,21 @@ abstract class AppDatabase : RoomDatabase() {
// --- TASK_LISTS TABLE ---
db.execSQL(
"""
CREATE TABLE task_lists_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL
)
""".trimIndent()
CREATE TABLE task_lists_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO task_lists_new (
id, name, display_order, deleted
)
SELECT
_id, -- old '_id' mapped to id
name,
displayorder, -- old 'displayorder' mapped to display_order
1 - visible -- old 'visible' mapped to deleted
FROM tasklist
""".trimIndent()
INSERT INTO task_lists_new (id, name, display_order, deleted)
SELECT _id, name, displayorder, 1 - visible
FROM tasklist
""".trimIndent()
)
db.execSQL("DROP TABLE tasklist")
@@ -128,8 +125,8 @@ abstract class AppDatabase : RoomDatabase() {
// insert default lists
CoroutineScope(Dispatchers.IO).launch {
val dao = DB_INSTANCE?.taskListDao()
dao?.insertTaskList(TaskListEntity(name = "Work", order = 2))
dao?.insertTaskList(TaskListEntity(name = "Personal", order = 1))
dao?.insertTaskList(TaskListEntity(name = "Work", order = 2))
dao?.insertTaskList(TaskListEntity(name = "Shopping", order = 3))
}
}

View File

@@ -6,13 +6,33 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskWithListNameEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks WHERE task_list_id = :listId ORDER BY done ASC, priority DESC")
@Query("SELECT * FROM tasks WHERE task_list_id = :listId AND deleted = 0 ORDER BY done ASC, priority DESC")
fun getTasksForList(listId: Long): Flow<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)
suspend fun insertTask(task: TaskEntity)
@@ -20,11 +40,17 @@ interface TaskDao {
suspend fun updateTask(task: TaskEntity)
@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")
suspend fun markTaskDeleted(taskId: Long, deleted: Boolean)
suspend fun toggleTaskDeleted(taskId: Long, deleted: Boolean)
@Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskListId")
suspend fun deleteAllTasksFromList(taskListId: Long, deleted: Boolean)
@Query("UPDATE tasks SET deleted = :deleted WHERE task_list_id = :taskListId")
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

@@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import kotlinx.coroutines.flow.Flow
@Dao
@@ -13,6 +14,30 @@ interface TaskListDao {
@Query("SELECT * FROM task_lists WHERE deleted = 0 ORDER BY display_order ASC")
fun getTaskLists(): Flow<List<TaskListEntity>>
@Query("""
SELECT
tl.id AS id,
tl.name AS name,
COALESCE(SUM(
CASE
WHEN t.done = 0
AND t.due_date IS NOT NULL
AND t.due_date < :nowMillis
THEN 1
ELSE 0
END
), 0) AS overdueCount
FROM task_lists tl
LEFT JOIN tasks t ON t.task_list_id = tl.id
WHERE tl.deleted = 0
GROUP BY tl.id
ORDER BY tl.display_order ASC
""")
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)
suspend fun insertTaskList(taskList: TaskListEntity)

View File

@@ -6,10 +6,13 @@ import com.wismna.geoffroy.donext.data.toDomain
import com.wismna.geoffroy.donext.data.toEntity
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import kotlin.collections.map
class TaskRepositoryImpl @Inject constructor(
private val taskDao: TaskDao,
@@ -19,6 +22,18 @@ class TaskRepositoryImpl @Inject constructor(
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) {
taskDao.insertTask(task.toEntity())
}
@@ -27,24 +42,44 @@ class TaskRepositoryImpl @Inject constructor(
taskDao.updateTask(task.toEntity())
}
override suspend fun deleteTask(taskId: Long, isDeleted: Boolean) {
taskDao.markTaskDeleted(taskId, isDeleted)
override suspend fun toggleTaskDeleted(taskId: Long, isDeleted: Boolean) {
taskDao.toggleTaskDeleted(taskId, isDeleted)
}
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>> {
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) {
taskListDao.insertTaskList(taskList.toEntity())
}
override suspend fun updateTaskList(taskList: TaskList) {
taskListDao.updateTaskList(taskList.toEntity())
}
override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) {
taskDao.deleteAllTasksFromList(taskListId, isDeleted)
taskDao.toggleAllTasksFromListDeleted(taskListId, isDeleted)
taskListDao.deleteTaskList(taskListId, isDeleted)
}
override fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> {
return taskListDao.getTaskListsWithOverdue(nowMillis).map { it }
}
}

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

@@ -1,7 +1,5 @@
package com.wismna.geoffroy.donext.domain.model
import java.time.Instant
data class Task(
val id: Long? = null,
val name: String,
@@ -10,5 +8,5 @@ data class Task(
val isDone: Boolean,
val isDeleted: Boolean,
val taskListId: Long,
val dueDate: Instant? = null
val dueDate: Long? = null
)

View File

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

View File

@@ -0,0 +1,7 @@
package com.wismna.geoffroy.donext.domain.model
data class TaskListWithOverdue(
val id: Long,
val name: String,
val overdueCount: Int
)

View File

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

View File

@@ -2,16 +2,26 @@ package com.wismna.geoffroy.donext.domain.repository
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.model.TaskWithListName
import kotlinx.coroutines.flow.Flow
interface TaskRepository {
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 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 permanentlyDeleteTask(taskId: Long)
suspend fun permanentlyDeleteAllDeletedTask()
fun getTaskLists(): Flow<List<TaskList>>
suspend fun getTaskListById(taskListId: Long): TaskList?
suspend fun insertTaskList(taskList: TaskList)
suspend fun updateTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
}

View File

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

View File

@@ -8,7 +8,7 @@ import javax.inject.Inject
class AddTaskUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(taskListId: Long, title: String, description: String?, priority: Priority) {
suspend operator fun invoke(taskListId: Long, title: String, description: String?, priority: Priority, dueDate: Long?) {
repository.insertTask(
Task(
taskListId = taskListId,
@@ -17,6 +17,7 @@ class AddTaskUseCase @Inject constructor(
isDeleted = false,
isDone = false,
priority = priority,
dueDate = dueDate
)
)
}

View File

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

View File

@@ -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

@@ -0,0 +1,19 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject
class GetTaskListsWithOverdueUseCase @Inject constructor(private val taskRepository: TaskRepository) {
operator fun invoke(): Flow<List<TaskListWithOverdue>> {
return taskRepository.getTaskListsWithOverdue(
LocalDate.now()
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
)
}
}

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 PermanentlyDeleteTaskUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(taskId: Long) {
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

@@ -0,0 +1,25 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class UpdateTaskUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(taskId: Long, taskListId: Long, title: String, description: String?, priority: Priority, dueDate: Long?, isDone: Boolean) {
repository.updateTask(
Task(
id = taskId,
taskListId = taskListId,
name = title,
description = description,
isDeleted = false,
isDone = isDone,
priority = priority,
dueDate = dueDate
)
)
}
}

View File

@@ -4,14 +4,9 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.foundation.isSystemInDarkTheme
import com.wismna.geoffroy.donext.presentation.screen.MainScreen
import com.wismna.geoffroy.donext.presentation.ui.theme.DoNextTheme
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -20,15 +15,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DoNextTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val viewModel: MainViewModel = hiltViewModel<MainViewModel>()
MainScreen(
viewModel,
modifier = Modifier.padding(innerPadding)
)
}
}
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

@@ -2,228 +2,232 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
fun MainScreen(
viewModel: MainViewModel,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier,
viewModel: MainViewModel = hiltViewModel()
) {
val navController = rememberNavController()
var showBottomSheet by remember { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// TODO: find a way to get rid of this
val taskViewModel: TaskViewModel = hiltViewModel()
if (viewModel.isLoading) {
// Show loading or empty state
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
val startDestination = viewModel.taskLists[0]
// TODO: get last opened tab from saved settings
var selectedDestination by rememberSaveable { mutableIntStateOf(0) }
return
}
if (showBottomSheet) {
AddTaskBottomSheet(viewModel, selectedDestination, { showBottomSheet = false })
}
if (viewModel.showTaskSheet) {
TaskBottomSheet(taskViewModel) { viewModel.showTaskSheet = false }
}
if (viewModel.showAddListSheet) {
AddListBottomSheet { viewModel.showAddListSheet = false }
}
Scaffold(
modifier = modifier,
floatingActionButton = {
AddNewTaskButton {
showBottomSheet = true
}
}, topBar = {
PrimaryTabRow(selectedTabIndex = selectedDestination) {
viewModel.taskLists.forEachIndexed { index, destination ->
Tab(
selected = selectedDestination == index,
onClick = {
navController.navigate(route = "taskList/${destination.id}")
selectedDestination = index
},
text = {
Text(
text = destination.name,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
viewModel.setCurrentDestination(navBackStackEntry)
ModalNavigationDrawer(
drawerContent = {
MenuScreen (
currentDestination = viewModel.currentDestination,
onNavigate = { route ->
scope.launch {
drawerState.close()
navController.navigate(route) {
restoreState = true
}
}
}
}) { contentPadding ->
Box(modifier = Modifier
.padding(contentPadding)
.fillMaxSize()
) {
AppNavHost(navController, startDestination, viewModel)
}
}
}
}
@Composable
fun AppNavHost(
navController: NavHostController,
startDestination: TaskList,
viewModel: MainViewModel
) {
NavHost(
navController,
startDestination = "taskList/${startDestination.id}"
)
},
drawerState = drawerState
) {
viewModel.taskLists.forEach { destination ->
composable(
route = "taskList/{taskListId}",
arguments = listOf(navArgument("taskListId") { type = NavType.LongType })) {
val viewModel: TaskListViewModel = hiltViewModel<TaskListViewModel>()
TaskListScreen(viewModel)
}
}
AppContent(viewModel = viewModel, taskViewModel = taskViewModel, navController = navController, scope = scope, drawerState = drawerState)
}
}
@Composable
fun AddTaskBottomSheet(
fun AppContent(
modifier : Modifier = Modifier,
viewModel: MainViewModel,
selectedListIndex: Int,
onDismiss: () -> Unit
taskViewModel: TaskViewModel,
navController: NavHostController,
scope: CoroutineScope,
drawerState: DrawerState
) {
val titleFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
titleFocusRequester.requestFocus()
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.padding(16.dp)) {
Text(
"New Task",
style = MaterialTheme.typography.titleLarge
)
Spacer(Modifier.height(8.dp))
// --- Title ---
OutlinedTextField(
value = viewModel.title,
singleLine = true,
onValueChange = { viewModel.onTitleChanged(it) },
label = { Text("Title") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester),
isError = !viewModel.isTitleValid && viewModel.title.isNotEmpty(),
)
Spacer(Modifier.height(8.dp))
// --- Description ---
OutlinedTextField(
value = viewModel.description,
onValueChange = { viewModel.onDescriptionChanged(it) },
label = { Text("Description") },
maxLines = 3,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
// --- Priority ---
Text("Priority", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(4.dp))
SingleChoiceSegmentedButton(
value = viewModel.priority,
onValueChange = { viewModel.onPriorityChanged(it) }
)
Spacer(Modifier.height(16.dp))
// --- Add Button ---
Button(
onClick = {
val currentListId = viewModel.taskLists[selectedListIndex].id
viewModel.createTask(currentListId)
onDismiss()
viewModel.resetTaskForm()
Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text(viewModel.currentDestination.title) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
navigationIcon = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (viewModel.currentDestination.showBackButton) {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
} else {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = "Open navigation drawer"
)
}
}
}
},
modifier = Modifier.align(Alignment.End)
) {
Text("Add")
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 = {
when (val dest = viewModel.currentDestination) {
is AppDestination.TaskList -> {
TaskListFab(
taskListId = dest.taskListId,
showBottomSheet = { viewModel.showTaskSheet = it }
)
}
else -> null
}
}
}
}
) { contentPadding ->
Surface(
modifier = Modifier
.padding(top = contentPadding.calculateTopPadding())
.fillMaxSize(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
NavHost(
navController = navController,
startDestination = viewModel.startDestination.route,
enterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(300))
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(300))
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
}
) {
composable(
route = "taskList/{taskListId}",
arguments = listOf(navArgument("taskListId") {
type = NavType.LongType
})
) { 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(
viewModel = taskListViewModel,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
)
}
@Composable
fun AddNewTaskButton(onClick: () -> Unit) {
ExtendedFloatingActionButton(
onClick = onClick,
icon = { Icon(Icons.Filled.Add, "Create a task.") },
text = { Text(text = "Create a task") },
)
}
@Composable
fun SingleChoiceSegmentedButton(
value: Priority,
onValueChange: (Priority) -> Unit) {
val options = listOf(Priority.LOW.label, Priority.NORMAL.label, Priority.HIGH.label)
SingleChoiceSegmentedButtonRow {
options.forEachIndexed { index, label ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size
),
onClick = { onValueChange(Priority.fromValue(index)) },
selected = index == value.value,
label = { Text(label) }
)
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
}
)
}
}
}
}
}

View File

@@ -0,0 +1,269 @@
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
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.DragHandle
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.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 com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ManageListsScreen(
modifier: Modifier,
viewModel: ManageListsViewModel = hiltViewModel(),
showAddListSheet: () -> Unit
) {
var lists = viewModel.taskLists.toMutableList()
val lazyListState = rememberLazyListState()
val reorderState = rememberReorderableLazyListState(
lazyListState = lazyListState,
onMove = { from, to ->
viewModel.moveTaskList(from.index, to.index)
}
)
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
fun AddListBottomSheet(
viewModel: ManageListsViewModel = hiltViewModel(),
onDismiss: () -> Unit
) {
val titleFocusRequester = remember { FocusRequester() }
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

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

@@ -0,0 +1,210 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.DeleteOutline
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.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun TaskItemScreen(
modifier: Modifier = Modifier,
viewModel: TaskItemViewModel,
onTaskClick: (taskId: Long) -> 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(
fontWeight = when (viewModel.priority) {
Priority.HIGH -> FontWeight.Bold
Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> FontWeight.Normal
},
color = when (viewModel.priority) {
Priority.HIGH -> MaterialTheme.colorScheme.onSurface
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
},
textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None
)
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(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(8.dp)
.alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f),
verticalAlignment = Alignment.CenterVertically // centers checkbox + content
) {
Box(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
.height(IntrinsicSize.Min) // shrink to fit title/description
) {
// Title
Text(
text = viewModel.name,
fontSize = 18.sp,
style = baseStyle,
modifier = Modifier
.align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterStart
else Alignment.TopStart
),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
// Due date badge
viewModel.dueDateText?.let { dueMillis ->
Badge(
modifier = Modifier
.align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterEnd
else Alignment.TopEnd
),
containerColor = if (viewModel.isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer
) {
Text(
modifier = Modifier.padding(start = 1.dp, end = 1.dp),
text = viewModel.dueDateText,
color = if (viewModel.isOverdue) Color.White else MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodySmall
)
}
}
// Optional description
Box(
modifier = Modifier
.fillMaxWidth()
.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,128 +1,114 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Priority
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
@Composable
fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) {
fun TaskListScreen(
modifier: Modifier = Modifier,
viewModel: TaskListViewModel = hiltViewModel<TaskListViewModel>(),
onTaskClick: (Task) -> Unit) {
val tasks = viewModel.tasks
// Split tasks into active and done
val (active, done) = remember(tasks) {
tasks.partition { !it.isDone }
}
val context = LocalContext.current
LazyColumn(
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(tasks, key = { _, task -> task.id!! }) { index, task ->
if (index > 0) {
val prev = tasks[index - 1]
when {
// 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))
}
}
}
TaskItem(
task = task,
onToggleDone = { isChecked ->
viewModel.updateTaskDone(task.id!!, isChecked)
// Active tasks section
items(
items = active,
key = { it.id!! }
) { task ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
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()
},
)
}
}
}
@Composable
fun TaskItem(
task: Task,
onToggleDone: (Boolean) -> Unit
fun TaskListFab(
taskListId: Long,
viewModel: TaskViewModel = hiltViewModel(),
showBottomSheet: (Boolean) -> Unit = {}
) {
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (task.priority) {
Priority.HIGH -> FontWeight.Bold
Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> FontWeight.Normal
},
color = when (task.priority) {
Priority.HIGH -> MaterialTheme.colorScheme.onSurface
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
},
textDecoration = if (task.isDone) TextDecoration.LineThrough else TextDecoration.None)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.alpha(if (task.isDone) 0.5f else 1f),
) {
/*Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {*/
Checkbox(
checked = task.isDone,
onCheckedChange = onToggleDone,
modifier = Modifier
.size(40.dp) // Adjust size as needed
.clip(CircleShape)
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = task.name,
style = baseStyle
)
if (!task.description.isNullOrBlank()) {
Text(
text = task.description,
style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize
),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
// }
}
ExtendedFloatingActionButton(
onClick = {
viewModel.startNewTask(taskListId)
showBottomSheet(true)
},
icon = { Icon(Icons.Filled.Add, "Create a task.") },
text = { Text("Create a task") },
)
}

View File

@@ -0,0 +1,235 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import com.wismna.geoffroy.donext.domain.extension.toLocalDate
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskBottomSheet(
viewModel: TaskViewModel,
onDismiss: () -> Unit
) {
val titleFocusRequester = remember { FocusRequester() }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
titleFocusRequester.requestFocus()
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState) {
Column(Modifier.padding(16.dp)) {
Text(
viewModel.screenTitle(),
style = MaterialTheme.typography.titleLarge
)
Spacer(Modifier.height(8.dp))
// --- Title ---
OutlinedTextField(
value = viewModel.title,
singleLine = true,
readOnly = viewModel.isDeleted,
onValueChange = { viewModel.onTitleChanged(it) },
label = { Text("Title") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester)
)
Spacer(Modifier.height(12.dp))
// --- Description ---
OutlinedTextField(
value = viewModel.description,
readOnly = viewModel.isDeleted,
onValueChange = { viewModel.onDescriptionChanged(it) },
label = { Text("Description") },
maxLines = 3,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
// --- Priority ---
Row (
modifier = Modifier.fillMaxWidth().padding(start = 17.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text("Priority", style = MaterialTheme.typography.labelLarge)
SingleChoiceSegmentedButton(
value = viewModel.priority,
isEnabled = !viewModel.isDeleted,
onValueChange = { viewModel.onPriorityChanged(it) }
)
}
Spacer(Modifier.height(12.dp))
// --- Due Date ---
var showDatePicker by remember { mutableStateOf(false) }
val formattedDate = viewModel.dueDate?.toLocalDate()?.format(
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
?: ""
OutlinedTextField(
value = formattedDate,
onValueChange = {},
readOnly = true,
label = { Text("Due Date") },
trailingIcon = {
Row {
if (viewModel.dueDate != null) {
IconButton(
onClick = { viewModel.onDueDateChanged(null) },
enabled = !viewModel.isDeleted) {
Icon(Icons.Default.Clear, contentDescription = "Clear due date")
}
}
IconButton(
onClick = { showDatePicker = true },
enabled = !viewModel.isDeleted) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick due date")
}
}
},
modifier = Modifier.fillMaxWidth()
)
if (showDatePicker) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = viewModel.dueDate,
selectableDates = object: SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val todayStartUtcMillis = LocalDate.now(ZoneId.systemDefault())
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
return utcTimeMillis >= todayStartUtcMillis
}
}
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
datePickerState.selectedDateMillis?.let { viewModel.onDueDateChanged(it) }
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
}
) {
DatePicker(state = datePickerState)
}
}
if (!viewModel.isDeleted) {
Spacer(Modifier.height(16.dp))
Row (
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// --- Cancel Button ---
Button(
onClick = {
scope.launch {
sheetState.hide()
onDismiss()
}
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
) { Text("Cancel") }
// --- Save Button ---
Button(
onClick = {
scope.launch {
viewModel.save()
sheetState.hide()
onDismiss()
}
},
enabled = viewModel.title.isNotBlank() && !viewModel.isDeleted,
) {
Text(if (viewModel.isEditing()) "Save" else "Create")
}
}
}
}
}
}
@Composable
fun SingleChoiceSegmentedButton(
value: Priority,
isEnabled: Boolean,
onValueChange: (Priority) -> Unit) {
val options = listOf(Priority.LOW.label, Priority.NORMAL.label, Priority.HIGH.label)
SingleChoiceSegmentedButtonRow {
options.forEachIndexed { index, label ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size
),
enabled = isEnabled,
onClick = { onValueChange(Priority.fromValue(index)) },
selected = index == value.value,
label = { Text(label) }
)
}
}
}

View File

@@ -2,10 +2,26 @@ package com.wismna.geoffroy.donext.presentation.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
// Primary shades
val Purple80 = Color(0xFF6A59C7) // Light theme primary
val Purple40 = Color(0xFF6650A4) // Dark theme primary
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val PurpleGrey80 = Color(0xFFCCC2DC)
val PurpleGrey40 = Color(0xFF625B71)
val Pink80 = Color(0xFFEFB8C8)
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.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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
fun DoNextTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
@@ -45,8 +23,51 @@ fun DoNextTheme(
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
darkTheme -> darkColorScheme(
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(
@@ -54,4 +75,4 @@ fun DoNextTheme(
typography = Typography,
content = content
)
}
}

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,67 +5,60 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase
import androidx.navigation.NavBackStackEntry
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
getTaskLists: GetTaskListsUseCase,
private val addTask: AddTaskUseCase
getTaskListsUseCase: GetTaskListsUseCase
) : ViewModel() {
var taskLists by mutableStateOf<List<TaskList>>(emptyList())
private set
var isLoading by mutableStateOf(true)
private set
// --- Form state ---
var title by mutableStateOf("")
private set
var description by mutableStateOf("")
private set
var priority by mutableStateOf(Priority.NORMAL)
var destinations by mutableStateOf<List<AppDestination>>(emptyList())
private set
val isTitleValid: Boolean
get() = title.isNotBlank()
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 {
getTaskLists()
getTaskListsUseCase()
.onEach { lists ->
taskLists = lists
destinations = lists.map { taskList ->
AppDestination.TaskList(taskList.id!!, taskList.name)
} +
AppDestination.ManageLists +
AppDestination.RecycleBin +
AppDestination.DueTodayList
isLoading = false
if (startDestination == AppDestination.ManageLists && destinations.isNotEmpty()) {
startDestination = destinations.first()
}
}
.launchIn(viewModelScope)
}
fun createTask(taskListId: Long) {
if (!isTitleValid) return
viewModelScope.launch {
addTask(taskListId, title, description, priority)
}
}
fun setCurrentDestination(navBackStackEntry: NavBackStackEntry?) {
val route = navBackStackEntry?.destination?.route
val taskListId = navBackStackEntry?.arguments?.getLong("taskListId")
fun onTitleChanged(newTitle: String) {
title = newTitle
}
fun onDescriptionChanged(newDesc: String) {
description = newDesc
}
fun onPriorityChanged(newPriority: Priority) {
priority = newPriority
}
fun resetTaskForm() {
title = ""
description = ""
priority = Priority.NORMAL
currentDestination = destinations.firstOrNull { dest ->
when (dest) {
is AppDestination.TaskList -> taskListId != null && dest.taskListId == taskListId
else -> dest.route == route
}
} ?: startDestination
}
}

View File

@@ -0,0 +1,74 @@
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.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskListUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ManageListsViewModel @Inject constructor(
getTaskListsUseCase: GetTaskListsUseCase,
private val addTaskListUseCase: AddTaskListUseCase,
private val updateTaskListUseCase: UpdateTaskListUseCase,
private val deleteTaskListUseCase: DeleteTaskListUseCase
) : ViewModel() {
var taskLists by mutableStateOf<List<TaskList>>(emptyList())
private set
var taskCount by mutableIntStateOf(0)
private set
init {
getTaskListsUseCase()
.onEach { lists ->
taskLists = lists
taskCount = lists.size
}
.launchIn(viewModelScope)
}
fun createTaskList(title: String, order: Int) {
viewModelScope.launch {
addTaskListUseCase(title, order)
}
}
fun updateTaskListName(taskList: TaskList) {
viewModelScope.launch {
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

@@ -0,0 +1,43 @@
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.Task
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
class TaskItemViewModel(task: Task) {
val id: Long = task.id!!
val name: String = task.name
val description: String? = task.description
val isDone: Boolean = task.isDone
val isDeleted: Boolean = task.isDeleted
val priority: Priority = task.priority
val today: LocalDate = LocalDate.now(ZoneId.systemDefault())
val isOverdue: Boolean = task.dueDate?.let { millis ->
val dueDate = millis.toLocalDate()
dueDate.isBefore(today)
} ?: false
val dueDateText: String? = task.dueDate?.let { formatDueDate(it) }
private fun formatDueDate(dueMillis: Long): String {
val dueDate = dueMillis.toLocalDate()
return when {
dueDate.isEqual(today) -> "Today"
dueDate.isEqual(today.plusDays(1)) -> "Tomorrow"
dueDate.isEqual(today.minusDays(1)) -> "Yesterday"
dueDate.isAfter(today) && dueDate.isBefore(today.plusDays(7)) ->
dueDate.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault())
else ->
dueDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()))
}
}
}

View File

@@ -7,8 +7,9 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.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
@@ -19,12 +20,12 @@ import javax.inject.Inject
class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
var tasks by mutableStateOf<List<Task>>(emptyList())
private set
var isLoading by mutableStateOf(true)
private set
@@ -44,4 +45,9 @@ class TaskListViewModel @Inject constructor(
toggleTaskDone(taskId, isDone)
}
}
fun deleteTask(taskId: Long) {
viewModelScope.launch {
toggleTaskDeleted(taskId, true)
}
}
}

View File

@@ -0,0 +1,100 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import javax.inject.Inject
@HiltViewModel
class TaskViewModel @Inject constructor(
private val createTaskUseCase: AddTaskUseCase,
private val updateTaskUseCase: UpdateTaskUseCase
) : ViewModel() {
var title by mutableStateOf("")
private set
var description by mutableStateOf("")
private set
var priority by mutableStateOf(Priority.NORMAL)
private set
var dueDate by mutableStateOf<Long?>(null)
private set
var isDone by mutableStateOf(false)
private set
var isDeleted by mutableStateOf(false)
private set
private var editingTaskId: 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 startNewTask(selectedListId: Long) {
editingTaskId = null
taskListId = selectedListId
title = ""
description = ""
priority = Priority.NORMAL
dueDate = null
isDeleted = false
}
fun startEditTask(task: Task) {
editingTaskId = task.id
taskListId = task.taskListId
title = task.name
description = task.description ?: ""
priority = task.priority
dueDate = task.dueDate
isDone = task.isDone
isDeleted = task.isDeleted
}
fun onTitleChanged(value: String) { title = value }
fun onDescriptionChanged(value: String) { description = value }
fun onPriorityChanged(value: Priority) { priority = 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) {
if (title.isBlank()) return
viewModelScope.launch {
if (isEditing()) {
updateTaskUseCase(editingTaskId!!, taskListId!!, title, description, priority, dueDate, isDone)
} else {
createTaskUseCase(taskListId!!, title, description, priority, dueDate)
}
// reset state after save
reset()
onDone?.invoke()
}
}
fun reset() {
editingTaskId = null
taskListId = null
title = ""
description = ""
priority = Priority.NORMAL
}
}

View File

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