mirror of
https://github.com/wismna/DoNext.git
synced 2025-10-03 07:30:13 -04:00
Add a FAB to create a task
Add bottom sheet to display the new task form Set up the tasks list screen to display tasks Implement a working migration from the old Donext database
This commit is contained in:
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
45
.idea/inspectionProfiles/Project_Default.xml
generated
45
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -4,5 +4,50 @@
|
||||
<inspection_tool class="AutoCloseableResource" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="METHOD_MATCHER_CONFIG" value="java.util.Formatter,format,java.io.Writer,append,com.google.common.base.Preconditions,checkNotNull,org.hibernate.Session,close,java.io.PrintWriter,printf,java.io.PrintStream,printf,android.content.Context,obtainStyledAttributes" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
@@ -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"
|
||||
}
|
||||
|
@@ -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"
|
||||
}
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@@ -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<TaskEntity>
|
||||
@Query("SELECT * FROM tasks WHERE task_list_id = :listId ORDER BY display_order ASC")
|
||||
fun getTasksForList(listId: Long): Flow<List<TaskEntity>>
|
||||
|
||||
@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)
|
||||
|
@@ -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<List<TaskListEntity>>
|
||||
|
||||
@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)
|
||||
}
|
@@ -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<Task> {
|
||||
return taskDao.getTasksForList(listId).map { it.toDomain() }
|
||||
override fun getTasksForList(listId: Long): Flow<List<Task>> {
|
||||
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) {
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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<Task>
|
||||
fun getTasksForList(listId: Long): Flow<List<Task>>
|
||||
suspend fun insertTask(task: Task)
|
||||
suspend fun updateTask(task: Task)
|
||||
suspend fun deleteTask(taskId: Long, isDeleted: Boolean)
|
||||
|
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
@@ -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<List<Task>> = repository.getTasksForList(taskListId)
|
||||
}
|
@@ -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
|
||||
@@ -50,65 +32,3 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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<TaskListViewModel>()
|
||||
TaskListScreen(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddNewTaskButton(onClick: () -> Unit) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { onClick() },
|
||||
icon = { Icon(Icons.Filled.Add, "Create a task.") },
|
||||
text = { Text(text = "Create a task") },
|
||||
)
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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<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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
@@ -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<List<Task>>(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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user