diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 83915d7..2c8262f 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -4,5 +4,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/donext/build.gradle b/donext/build.gradle index b1488fc..c86fc15 100644 --- a/donext/build.gradle +++ b/donext/build.gradle @@ -1,12 +1,12 @@ apply plugin: 'com.android.application' android { - compileSdk 35 + compileSdk 36 defaultConfig { applicationId "com.wismna.geoffroy.donext" minSdkVersion 21 - targetSdkVersion 35 + targetSdkVersion 36 versionCode 33 versionName "1.12" } diff --git a/donextv2/build.gradle.kts b/donextv2/build.gradle.kts index 349224d..055710a 100644 --- a/donextv2/build.gradle.kts +++ b/donextv2/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.wismna.geoffroy.donext" minSdk = 26 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + versionCode = 34 + versionName = "2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/Mappers.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/Mappers.kt index 041a8b8..59736c7 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/Mappers.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/Mappers.kt @@ -9,33 +9,39 @@ import java.time.Instant fun TaskEntity.toDomain() = Task( id = id, name = name, - isDone = isDone, taskListId = taskListId, description = description, - cycles = cycles, + cycle = cycle, + isDone = isDone, isDeleted = isDeleted, - updateDate = Instant.ofEpochMilli(updateDate) + dueDate = if (dueDate == null) null else Instant.ofEpochMilli(dueDate), + priority = priority, + order = order ) fun Task.toEntity() = TaskEntity( - id = id, + id = id ?: 0, name = name, - isDone = isDone, taskListId = taskListId, description = description, - cycles = cycles, + cycle = cycle, + priority = priority, + order = order, + isDone = isDone, isDeleted = isDeleted, - updateDate = updateDate.toEpochMilli() + dueDate = dueDate?.toEpochMilli() ) fun TaskListEntity.toDomain() = TaskList( id = id, name = name, - isDeleted = isDeleted + isDeleted = isDeleted, + order = order ) fun TaskList.toEntity() = TaskListEntity( id = id, name = name, - isDeleted = isDeleted + isDeleted = isDeleted, + order = order ) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/entities/TaskEntity.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/entities/TaskEntity.kt index 4eacfd8..3c6c366 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/entities/TaskEntity.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/entities/TaskEntity.kt @@ -9,14 +9,17 @@ data class TaskEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, - val description: String, - val cycles: Int = 0, + val description: String?, + val cycle: Int = 0, + val priority: Int, + @ColumnInfo(name = "display_order") + val order: Int, @ColumnInfo(name = "done") val isDone: Boolean = false, @ColumnInfo(name = "deleted") val isDeleted: Boolean = false, @ColumnInfo(name = "task_list_id") val taskListId: Long, - @ColumnInfo(name = "update_date") - val updateDate: Long = System.currentTimeMillis() + @ColumnInfo(name = "due_date") + val dueDate: Long? = null ) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/entities/TaskListEntity.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/entities/TaskListEntity.kt index 5d4bd43..3a4e599 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/entities/TaskListEntity.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/entities/TaskListEntity.kt @@ -1,5 +1,6 @@ package com.wismna.geoffroy.donext.data.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -8,5 +9,8 @@ data class TaskListEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, + @ColumnInfo(name = "display_order") + val order: Int, + @ColumnInfo(name = "deleted") val isDeleted: Boolean = false ) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/Database.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/Database.kt index 7a5b189..28242ff 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/Database.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/Database.kt @@ -31,7 +31,89 @@ abstract class AppDatabase : RoomDatabase() { val MIGRATION_6_7 = object : Migration(6, 7) { override fun migrate(db: SupportSQLiteDatabase) { - // TODO: migrate from old Donext database (v6) + db.beginTransaction() + try { + // --- TASKS TABLE --- + // 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, + cycle INTEGER NOT NULL DEFAULT 0, + priority INTEGER NOT NULL, + display_order 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, cycle, priority, display_order, + done, deleted, task_list_id, due_date + ) + SELECT + _id, -- old '_id' mapped to id + name, + description, + cycle, + priority, + displayorder, -- old 'displayorder' mapped to display_order + done, + deleted, + list, -- old 'list' mapped to task_list_id + duedate -- old column renamed to due_date + FROM tasks + """.trimIndent() + ) + + // 3. Drop the old table + db.execSQL("DROP TABLE tasks") + + // 4. Rename the new table + db.execSQL("ALTER TABLE tasks_new RENAME TO tasks") + + // --- 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() + ) + + 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() + ) + + db.execSQL("DROP TABLE tasklist") + db.execSQL("ALTER TABLE task_lists_new RENAME TO task_lists") + + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } } } @@ -50,9 +132,9 @@ abstract class AppDatabase : RoomDatabase() { // insert default lists CoroutineScope(Dispatchers.IO).launch { val dao = DB_INSTANCE?.taskListDao() - dao?.insertTaskList(TaskListEntity(name = "Work")) - dao?.insertTaskList(TaskListEntity(name = "Personal")) - dao?.insertTaskList(TaskListEntity(name = "Shopping")) + dao?.insertTaskList(TaskListEntity(name = "Work", order = 2)) + dao?.insertTaskList(TaskListEntity(name = "Personal", order = 1)) + dao?.insertTaskList(TaskListEntity(name = "Shopping", order = 3)) } } }) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt index d33798c..dcf9957 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskDao.kt @@ -6,11 +6,12 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import com.wismna.geoffroy.donext.data.entities.TaskEntity +import kotlinx.coroutines.flow.Flow @Dao interface TaskDao { - @Query("SELECT * FROM tasks WHERE task_list_id = :listId") - suspend fun getTasksForList(listId: Long): List + @Query("SELECT * FROM tasks WHERE task_list_id = :listId ORDER BY display_order ASC") + fun getTasksForList(listId: Long): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTask(task: TaskEntity) @@ -18,14 +19,14 @@ interface TaskDao { @Update suspend fun updateTask(task: TaskEntity) - @Query("UPDATE tasks SET done = :done, update_date = :updateDate WHERE id = :taskId") - suspend fun markTaskDone(taskId: Long, done: Boolean, updateDate: Long = System.currentTimeMillis()) + @Query("UPDATE tasks SET done = :done WHERE id = :taskId") + suspend fun markTaskDone(taskId: Long, done: Boolean) - @Query("UPDATE tasks SET deleted = :deleted, update_date = :updateDate WHERE id = :taskId") - suspend fun markTaskDeleted(taskId: Long, deleted: Boolean, updateDate: Long = System.currentTimeMillis()) + @Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskId") + suspend fun markTaskDeleted(taskId: Long, deleted: Boolean) - @Query("UPDATE tasks SET cycles = cycles + 1, update_date = :updateDate WHERE id = :taskId") - suspend fun increaseCycle(taskId: Long, updateDate: Long = System.currentTimeMillis()) + @Query("UPDATE tasks SET cycle = cycle + 1 WHERE id = :taskId") + suspend fun increaseCycle(taskId: Long) @Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskListId") suspend fun deleteAllTasksFromList(taskListId: Long, deleted: Boolean) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt index ec5d7a1..affbde2 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDao.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow @Dao interface TaskListDao { - @Query("SELECT * FROM task_lists WHERE isDeleted = 0") + @Query("SELECT * FROM task_lists WHERE deleted = 0 ORDER BY display_order ASC") fun getTaskLists(): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -19,6 +19,6 @@ interface TaskListDao { @Update suspend fun updateTaskList(taskList: TaskListEntity) - @Query("UPDATE task_lists SET isDeleted = :isDeleted WHERE id = :listId") + @Query("UPDATE task_lists SET deleted = :isDeleted WHERE id = :listId") suspend fun deleteTaskList(listId: Long, isDeleted: Boolean) } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt index d0244ad..f650190 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/repository/TaskRepositoryImpl.kt @@ -9,15 +9,14 @@ import com.wismna.geoffroy.donext.domain.model.TaskList import com.wismna.geoffroy.donext.domain.repository.TaskRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import java.time.Instant import javax.inject.Inject class TaskRepositoryImpl @Inject constructor( private val taskDao: TaskDao, private val taskListDao: TaskListDao ): TaskRepository { - override suspend fun getTasksForList(listId: Long): List { - return taskDao.getTasksForList(listId).map { it.toDomain() } + override fun getTasksForList(listId: Long): Flow> { + return taskDao.getTasksForList(listId).map {entity -> entity.map { it.toDomain() }} } override suspend fun insertTask(task: Task) { @@ -25,8 +24,7 @@ class TaskRepositoryImpl @Inject constructor( } override suspend fun updateTask(task: Task) { - val updated = task.copy(updateDate = Instant.now()) - taskDao.updateTask(updated.toEntity()) + taskDao.updateTask(task.toEntity()) } override suspend fun deleteTask(taskId: Long, isDeleted: Boolean) { diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/Task.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/Task.kt index 3bc2ba3..e9f80ab 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/Task.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/Task.kt @@ -3,12 +3,14 @@ package com.wismna.geoffroy.donext.domain.model import java.time.Instant data class Task( - val id: Long, + val id: Long? = null, val name: String, - val description: String, - val cycles: Int, + val description: String?, + val cycle: Int, + val priority: Int, + val order: Int, val isDone: Boolean, val isDeleted: Boolean, val taskListId: Long, - val updateDate: Instant = Instant.now() + val dueDate: Instant? = null ) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskList.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskList.kt index 33c39b0..dc88650 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskList.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/model/TaskList.kt @@ -3,5 +3,6 @@ package com.wismna.geoffroy.donext.domain.model data class TaskList( val id: Long, val name: String, - val isDeleted: Boolean + val isDeleted: Boolean, + val order: Int ) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt index 1d2af95..854e07a 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/TaskRepository.kt @@ -5,7 +5,7 @@ import com.wismna.geoffroy.donext.domain.model.TaskList import kotlinx.coroutines.flow.Flow interface TaskRepository { - suspend fun getTasksForList(listId: Long): List + fun getTasksForList(listId: Long): Flow> suspend fun insertTask(task: Task) suspend fun updateTask(task: Task) suspend fun deleteTask(taskId: Long, isDeleted: Boolean) diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskUseCase.kt new file mode 100644 index 0000000..3c6cb5b --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/AddTaskUseCase.kt @@ -0,0 +1,24 @@ +package com.wismna.geoffroy.donext.domain.usecase + +import com.wismna.geoffroy.donext.domain.model.Task +import com.wismna.geoffroy.donext.domain.repository.TaskRepository +import javax.inject.Inject + +class AddTaskUseCase @Inject constructor( + private val repository: TaskRepository +) { + suspend operator fun invoke(taskListId: Long, title: String, description: String?) { + repository.insertTask( + Task( + taskListId = taskListId, + name = title, + description = description ?: "", + isDeleted = false, + cycle = 0, + isDone = false, + priority = 0, + order = 0 + ) + ) + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTasksForListUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTasksForListUseCase.kt new file mode 100644 index 0000000..c37fa05 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetTasksForListUseCase.kt @@ -0,0 +1,10 @@ +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 javax.inject.Inject + +class GetTasksForListUseCase @Inject constructor(private val repository: TaskRepository) { + operator fun invoke(taskListId: Long): Flow> = repository.getTasksForList(taskListId) +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt index 2ea1351..cced042 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt @@ -4,30 +4,12 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import com.wismna.geoffroy.donext.domain.model.TaskList -import com.wismna.geoffroy.donext.presentation.screen.TaskListScreen +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 @@ -49,66 +31,4 @@ class MainActivity : ComponentActivity() { } } } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { - val navController = rememberNavController() - - 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) } - - Scaffold(modifier = modifier) { contentPadding -> - PrimaryTabRow( - selectedTabIndex = selectedDestination, - modifier = Modifier.padding(contentPadding) - ) { - viewModel.taskLists.forEachIndexed { index, destination -> - Tab( - selected = selectedDestination == index, - onClick = { - navController.navigate(route = destination.name) - selectedDestination = index - }, - text = { - Text( - text = destination.name, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - ) - } - } - AppNavHost(navController, startDestination, viewModel) - } - } -} - -@Composable -fun AppNavHost( - navController: NavHostController, - startDestination: TaskList, - viewModel: MainViewModel, - modifier: Modifier = Modifier -) { - NavHost( - navController, - startDestination = startDestination.name - ) { - viewModel.taskLists.forEach { destination -> - composable(destination.name) { - TaskListScreen(destination, modifier) - } - } - } -} - +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt new file mode 100644 index 0000000..6fb1d1f --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt @@ -0,0 +1,165 @@ +package com.wismna.geoffroy.donext.presentation.screen + +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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.wismna.geoffroy.donext.domain.model.TaskList +import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel +import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + viewModel: MainViewModel, + modifier: Modifier = Modifier) { + val navController = rememberNavController() + var showBottomSheet by remember { mutableStateOf(false) } + + 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) } + + if (showBottomSheet) { + ModalBottomSheet(onDismissRequest = { showBottomSheet = false }) { + Column(Modifier.padding(16.dp)) { + Text("New Task", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = viewModel.title, + singleLine = true, + onValueChange = { viewModel.onTitleChanged(it) }, + label = { Text("Title") }, + modifier = Modifier.fillMaxWidth(), + isError = !viewModel.isTitleValid && viewModel.title.isNotEmpty(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = viewModel.description, + onValueChange = { viewModel.onDescriptionChanged(it) }, + label = { Text("Description") }, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + val currentListId = viewModel.taskLists[selectedDestination].id + viewModel.createTask(currentListId) + showBottomSheet = false + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Add") + } + } + } + } + + 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 + ) + } + ) + } + } + }) { contentPadding -> + // NavHost will now automatically be below the tabs + 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}" + ) { + viewModel.taskLists.forEach { destination -> + composable( + route = "taskList/{taskListId}", + arguments = listOf(navArgument("taskListId") { type = NavType.LongType })) { + val viewModel: TaskListViewModel = hiltViewModel() + TaskListScreen(viewModel) + } + } + } +} + +@Composable +fun AddNewTaskButton(onClick: () -> Unit) { + ExtendedFloatingActionButton( + onClick = { onClick() }, + icon = { Icon(Icons.Filled.Add, "Create a task.") }, + text = { Text(text = "Create a task") }, + ) +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt index d16e625..c52ed97 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt @@ -1,19 +1,43 @@ package com.wismna.geoffroy.donext.presentation.screen -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.wismna.geoffroy.donext.domain.model.TaskList +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel @Composable -fun TaskListScreen(taskList: TaskList, modifier: Modifier = Modifier) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text("${taskList.name} Screen") +fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) { + val tasks = viewModel.tasks + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(tasks) { task -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Column { + } + Column { + val textStyle = TextStyle(textDecoration = if (task.isDeleted) TextDecoration.LineThrough else TextDecoration.None) + Text(task.name, fontSize = 18.sp, color = Color.DarkGray, style = textStyle) + Text(task.description.orEmpty(), fontSize = 14.sp, color = Color.LightGray, style = textStyle, maxLines = 3) + } + } + } } } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt index 0270dca..5f312f9 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt @@ -6,23 +6,34 @@ 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.AddTaskUseCase 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 + getTaskLists: GetTaskListsUseCase, + private val addTask: AddTaskUseCase ) : ViewModel() { var taskLists by mutableStateOf>(emptyList()) private set - var isLoading by mutableStateOf(true) private set + // --- Form state --- + var title by mutableStateOf("") + private set + var description by mutableStateOf("") + private set + + val isTitleValid: Boolean + get() = title.isNotBlank() + init { getTaskLists() .onEach { lists -> @@ -31,4 +42,19 @@ class MainViewModel @Inject constructor( } .launchIn(viewModelScope) } + + fun createTask(taskListId: Long) { + if (!isTitleValid) return + viewModelScope.launch { + addTask(taskListId, title, description) + } + } + + fun onTitleChanged(newTitle: String) { + title = newTitle + } + + fun onDescriptionChanged(newDesc: String) { + description = newDesc + } } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt new file mode 100644 index 0000000..e6286b2 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModel.kt @@ -0,0 +1,38 @@ +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.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wismna.geoffroy.donext.domain.model.Task +import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@HiltViewModel +class TaskListViewModel @Inject constructor( + getTasks: GetTasksForListUseCase, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + var tasks by mutableStateOf>(emptyList()) + private set + + var isLoading by mutableStateOf(true) + private set + + private val taskListId: Long = checkNotNull(savedStateHandle["taskListId"]) + + init { + getTasks(taskListId) + .onEach { list -> + tasks = list + isLoading = false + } + .launchIn(viewModelScope) + } +} \ No newline at end of file