Merge pull request 'Milestone/lot 2 navigation' (#32) from milestone/lot-2-navigation into main
Reviewed-on: Gato/Planify-mobile#32
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package com.planify.mobile.data.repository
|
||||
|
||||
import com.planify.mobile.data.local.dao.ProjectDao
|
||||
import com.planify.mobile.data.local.entity.ProjectEntity
|
||||
import com.planify.mobile.domain.model.BackendType
|
||||
import com.planify.mobile.domain.model.Project
|
||||
import com.planify.mobile.domain.model.SortBy
|
||||
import com.planify.mobile.domain.model.ViewStyle
|
||||
import com.planify.mobile.domain.repository.ProjectRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class ProjectRepositoryImpl @Inject constructor(
|
||||
private val dao: ProjectDao,
|
||||
) : ProjectRepository {
|
||||
|
||||
override fun getAllProjects(): Flow<List<Project>> =
|
||||
dao.getAllProjects().map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override fun getFavoriteProjects(): Flow<List<Project>> =
|
||||
dao.getFavoriteProjects().map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override fun getSubProjects(parentId: String): Flow<List<Project>> =
|
||||
dao.getSubProjects(parentId).map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override suspend fun getProjectById(id: String): Project? =
|
||||
dao.getById(id)?.toDomain()
|
||||
|
||||
override suspend fun getInboxProject(): Project? =
|
||||
dao.getInboxProject()?.toDomain()
|
||||
|
||||
override suspend fun insertProject(project: Project) =
|
||||
dao.insert(project.toEntity())
|
||||
|
||||
override suspend fun updateProject(project: Project) =
|
||||
dao.update(project.toEntity())
|
||||
|
||||
override suspend fun deleteProject(id: String) =
|
||||
dao.softDelete(id)
|
||||
|
||||
private fun ProjectEntity.toDomain() = Project(
|
||||
id = id, name = name, color = color, emoji = emoji,
|
||||
parentId = parentId, sourceId = sourceId,
|
||||
backendType = runCatching { BackendType.valueOf(backendType) }.getOrDefault(BackendType.LOCAL),
|
||||
isInbox = isInbox, isFavorite = isFavorite, isArchived = isArchived, isDeleted = isDeleted,
|
||||
viewStyle = runCatching { ViewStyle.valueOf(viewStyle) }.getOrDefault(ViewStyle.LIST),
|
||||
sortedBy = runCatching { SortBy.valueOf(sortedBy) }.getOrDefault(SortBy.MANUAL),
|
||||
sortAscending = sortAscending, childOrder = childOrder,
|
||||
calendarUrl = calendarUrl, syncId = syncId,
|
||||
)
|
||||
|
||||
private fun Project.toEntity() = ProjectEntity(
|
||||
id = id, name = name, color = color, emoji = emoji,
|
||||
parentId = parentId, sourceId = sourceId, backendType = backendType.name,
|
||||
isInbox = isInbox, isFavorite = isFavorite, isArchived = isArchived, isDeleted = isDeleted,
|
||||
viewStyle = viewStyle.name, sortedBy = sortedBy.name,
|
||||
sortAscending = sortAscending, childOrder = childOrder,
|
||||
calendarUrl = calendarUrl, syncId = syncId,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.planify.mobile.data.repository
|
||||
|
||||
import com.planify.mobile.data.local.dao.SectionDao
|
||||
import com.planify.mobile.data.local.entity.SectionEntity
|
||||
import com.planify.mobile.domain.model.Section
|
||||
import com.planify.mobile.domain.repository.SectionRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class SectionRepositoryImpl @Inject constructor(
|
||||
private val dao: SectionDao,
|
||||
) : SectionRepository {
|
||||
|
||||
override fun getSectionsByProject(projectId: String): Flow<List<Section>> =
|
||||
dao.getSectionsByProject(projectId).map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override suspend fun getSectionById(id: String): Section? =
|
||||
dao.getById(id)?.toDomain()
|
||||
|
||||
override suspend fun insertSection(section: Section) = dao.insert(section.toEntity())
|
||||
override suspend fun updateSection(section: Section) = dao.update(section.toEntity())
|
||||
override suspend fun deleteSection(id: String) = dao.softDelete(id)
|
||||
|
||||
private fun SectionEntity.toDomain() = Section(
|
||||
id = id, name = name, projectId = projectId, order = order,
|
||||
isArchived = isArchived, isDeleted = isDeleted, collapsed = collapsed,
|
||||
icalUrl = icalUrl, etag = etag,
|
||||
)
|
||||
|
||||
private fun Section.toEntity() = SectionEntity(
|
||||
id = id, name = name, projectId = projectId, order = order,
|
||||
isArchived = isArchived, isDeleted = isDeleted, collapsed = collapsed,
|
||||
icalUrl = icalUrl, etag = etag,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.planify.mobile.data.repository
|
||||
|
||||
import com.planify.mobile.data.local.dao.TaskDao
|
||||
import com.planify.mobile.data.local.entity.TaskEntity
|
||||
import com.planify.mobile.domain.model.DueDate
|
||||
import com.planify.mobile.domain.model.ItemType
|
||||
import com.planify.mobile.domain.model.RecurrencyType
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.domain.repository.TaskRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
class TaskRepositoryImpl @Inject constructor(
|
||||
private val dao: TaskDao,
|
||||
) : TaskRepository {
|
||||
|
||||
override fun getTasksByProject(projectId: String): Flow<List<Task>> =
|
||||
dao.getTasksByProject(projectId).map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getTasksBySection(sectionId: String): Flow<List<Task>> =
|
||||
dao.getTasksBySection(sectionId).map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getInboxTasks(): Flow<List<Task>> =
|
||||
dao.getInboxTasks().map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getTodayTasks(): Flow<List<Task>> =
|
||||
dao.getTodayTasks().map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getOverdueTasks(): Flow<List<Task>> =
|
||||
dao.getOverdueTasks().map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getSubTasks(parentId: String): Flow<List<Task>> =
|
||||
dao.getSubTasks(parentId).map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override suspend fun getTaskById(id: String): Task? =
|
||||
dao.getTaskById(id)?.toDomain()
|
||||
|
||||
override suspend fun insertTask(task: Task) =
|
||||
dao.insert(task.toEntity())
|
||||
|
||||
override suspend fun updateTask(task: Task) =
|
||||
dao.update(task.toEntity())
|
||||
|
||||
override suspend fun deleteTask(id: String) {
|
||||
val now = DateTimeFormatter.ISO_INSTANT.format(Instant.now().atOffset(ZoneOffset.UTC))
|
||||
dao.softDelete(id, now)
|
||||
}
|
||||
|
||||
override suspend fun checkTask(id: String, checked: Boolean) {
|
||||
val now = DateTimeFormatter.ISO_INSTANT.format(Instant.now().atOffset(ZoneOffset.UTC))
|
||||
dao.setChecked(id, checked, if (checked) now else null, now)
|
||||
}
|
||||
|
||||
private fun TaskEntity.toDomain() = Task(
|
||||
id = id,
|
||||
content = content,
|
||||
description = description,
|
||||
projectId = projectId,
|
||||
sectionId = sectionId,
|
||||
parentId = parentId,
|
||||
priority = priority,
|
||||
checked = checked,
|
||||
dueDate = dueDate?.let { runCatching { Json.decodeFromString<DueDate>(it) }.getOrNull() },
|
||||
deadlineDate = deadlineDate,
|
||||
labels = runCatching { Json.decodeFromString<List<String>>(labels) }.getOrDefault(emptyList()),
|
||||
pinned = pinned,
|
||||
collapsed = collapsed,
|
||||
childOrder = childOrder,
|
||||
addedAt = addedAt,
|
||||
updatedAt = updatedAt,
|
||||
completedAt = completedAt,
|
||||
itemType = runCatching { ItemType.valueOf(itemType) }.getOrDefault(ItemType.TASK),
|
||||
calendarEventUid = calendarEventUid,
|
||||
icalUrl = icalUrl,
|
||||
etag = etag,
|
||||
responsibleUid = responsibleUid,
|
||||
)
|
||||
|
||||
private fun Task.toEntity() = TaskEntity(
|
||||
id = id,
|
||||
content = content,
|
||||
description = description,
|
||||
projectId = projectId,
|
||||
sectionId = sectionId,
|
||||
parentId = parentId,
|
||||
priority = priority,
|
||||
checked = checked,
|
||||
dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) },
|
||||
deadlineDate = deadlineDate,
|
||||
labels = Json.encodeToString(kotlinx.serialization.builtins.ListSerializer(kotlinx.serialization.builtins.serializer()), labels),
|
||||
pinned = pinned,
|
||||
collapsed = collapsed,
|
||||
childOrder = childOrder,
|
||||
addedAt = addedAt,
|
||||
updatedAt = updatedAt,
|
||||
completedAt = completedAt,
|
||||
itemType = itemType.name,
|
||||
calendarEventUid = calendarEventUid,
|
||||
icalUrl = icalUrl,
|
||||
etag = etag,
|
||||
responsibleUid = responsibleUid,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.planify.mobile.di
|
||||
|
||||
import com.planify.mobile.data.repository.ProjectRepositoryImpl
|
||||
import com.planify.mobile.data.repository.SectionRepositoryImpl
|
||||
import com.planify.mobile.data.repository.TaskRepositoryImpl
|
||||
import com.planify.mobile.domain.repository.ProjectRepository
|
||||
import com.planify.mobile.domain.repository.SectionRepository
|
||||
import com.planify.mobile.domain.repository.TaskRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindProjectRepository(impl: ProjectRepositoryImpl): ProjectRepository
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindTaskRepository(impl: TaskRepositoryImpl): TaskRepository
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindSectionRepository(impl: SectionRepositoryImpl): SectionRepository
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.planify.mobile.domain.repository
|
||||
|
||||
import com.planify.mobile.domain.model.Section
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface SectionRepository {
|
||||
fun getSectionsByProject(projectId: String): Flow<List<Section>>
|
||||
suspend fun getSectionById(id: String): Section?
|
||||
suspend fun insertSection(section: Section)
|
||||
suspend fun updateSection(section: Section)
|
||||
suspend fun deleteSection(id: String)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
PlanifyTheme {
|
||||
// TODO #6 : PlanifyNavHost()
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.planify.mobile.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.outlined.Inbox
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Today
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.planify.mobile.ui.navigation.DrawerViewModel
|
||||
import com.planify.mobile.ui.navigation.PlanifyNavHost
|
||||
import com.planify.mobile.ui.navigation.Route
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val projects by viewModel.projects.collectAsState()
|
||||
val navBackStack by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStack?.destination?.route
|
||||
|
||||
val drawerTitles = mapOf(
|
||||
Route.Inbox.path to "Inbox",
|
||||
Route.Today.path to "Aujourd'hui",
|
||||
Route.Scheduled.path to "Planifié",
|
||||
)
|
||||
val title = drawerTitles[currentRoute]
|
||||
?: projects.find { "project/${it.id}" == currentRoute }?.name
|
||||
?: "Planify"
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
Text(
|
||||
text = "Planify",
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Outlined.Inbox, null) },
|
||||
label = { Text("Inbox") },
|
||||
selected = currentRoute == Route.Inbox.path,
|
||||
onClick = {
|
||||
navController.navigate(Route.Inbox.path)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Outlined.Today, null) },
|
||||
label = { Text("Aujourd'hui") },
|
||||
selected = currentRoute == Route.Today.path,
|
||||
onClick = {
|
||||
navController.navigate(Route.Today.path)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Projets",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
LazyColumn {
|
||||
items(projects) { project ->
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Default.FolderOpen, null) },
|
||||
label = { Text(project.name) },
|
||||
selected = currentRoute == "project/${project.id}",
|
||||
onClick = {
|
||||
navController.navigate(Route.Project().buildRoute(project.id))
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Outlined.Settings, null) },
|
||||
label = { Text("Paramètres") },
|
||||
selected = false,
|
||||
onClick = { scope.launch { drawerState.close() } },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Outlined.Menu, contentDescription = "Menu")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
PlanifyNavHost(
|
||||
navController = navController,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.planify.mobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CalendarToday
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@Composable
|
||||
fun DueDateChip(dateIso: String, modifier: Modifier = Modifier) {
|
||||
val date = runCatching { LocalDate.parse(dateIso.take(10)) }.getOrNull() ?: return
|
||||
val today = LocalDate.now()
|
||||
val overdue = date.isBefore(today)
|
||||
val color = if (overdue) Color(0xFFE53935) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
val label = when (date) {
|
||||
today -> "Aujourd'hui"
|
||||
today.plusDays(1) -> "Demain"
|
||||
else -> date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
|
||||
}
|
||||
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.CalendarToday,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.width(14.dp)
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Text(text = label, style = MaterialTheme.typography.labelSmall, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun DueDateChipPreview() {
|
||||
DueDateChip(dateIso = LocalDate.now().toString())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.planify.mobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun EmptyStatePreview() {
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.CheckCircle,
|
||||
title = "Aucune tâche",
|
||||
subtitle = "Créez votre première tâche avec le bouton +",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.planify.mobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun LabelChip(name: String, color: Color, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
modifier = modifier
|
||||
.background(color.copy(alpha = 0.12f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun LabelChipPreview() {
|
||||
LabelChip(name = "android", color = Color(0xFF1E88E5))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.planify.mobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val priorityColor = mapOf(
|
||||
1 to Color(0xFFE53935),
|
||||
2 to Color(0xFFFB8C00),
|
||||
3 to Color(0xFF1E88E5),
|
||||
4 to Color(0xFF9E9E9E),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PriorityBadge(priority: Int, modifier: Modifier = Modifier) {
|
||||
val color = priorityColor[priority] ?: priorityColor[4]!!
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(12.dp)
|
||||
.background(color, CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PriorityBadgePreview() {
|
||||
PriorityBadge(priority = 1)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.planify.mobile.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SectionHeader(
|
||||
name: String,
|
||||
taskCount: Int,
|
||||
collapsed: Boolean,
|
||||
onToggleCollapse: () -> Unit,
|
||||
onAddTask: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val rotation by animateFloatAsState(if (collapsed) -90f else 0f, label = "collapse")
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onToggleCollapse() }
|
||||
.padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
contentDescription = if (collapsed) "Déplier" else "Replier",
|
||||
modifier = Modifier.rotate(rotation),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (taskCount > 0) {
|
||||
Text(
|
||||
text = "$taskCount",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onAddTask) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Ajouter une tâche")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SectionHeaderPreview() {
|
||||
SectionHeader(
|
||||
name = "En cours",
|
||||
taskCount = 3,
|
||||
collapsed = false,
|
||||
onToggleCollapse = {},
|
||||
onAddTask = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.planify.mobile.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.foundation.layout.width
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.planify.mobile.domain.model.Task
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TaskRow(
|
||||
task: Task,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val priorityColor = priorityColor[task.priority] ?: Color.Gray
|
||||
val textColor by animateColorAsState(
|
||||
if (task.checked) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
label = "textColor",
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onClick, onLongClick = {})
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = task.checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = priorityColor,
|
||||
uncheckedColor = priorityColor,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = task.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
textDecoration = if (task.checked) TextDecoration.LineThrough else null,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (task.dueDate != null) {
|
||||
DueDateChip(dateIso = task.dueDate.date)
|
||||
}
|
||||
task.labels.take(2).forEach { labelName ->
|
||||
LabelChip(name = labelName, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (task.priority < 4) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
PriorityBadge(priority = task.priority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun TaskRowPreview() {
|
||||
Surface {
|
||||
Column {
|
||||
TaskRow(
|
||||
task = Task(
|
||||
id = "1",
|
||||
content = "Implémenter la navigation principale",
|
||||
projectId = "p1",
|
||||
priority = 2,
|
||||
labels = listOf("android", "ui"),
|
||||
),
|
||||
onCheckedChange = {},
|
||||
onClick = {},
|
||||
)
|
||||
Spacer(Modifier.height(1.dp))
|
||||
TaskRow(
|
||||
task = Task(
|
||||
id = "2",
|
||||
content = "Tâche terminée",
|
||||
projectId = "p1",
|
||||
priority = 4,
|
||||
checked = true,
|
||||
),
|
||||
onCheckedChange = {},
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.planify.mobile.ui.inbox
|
||||
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Inbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.ui.components.EmptyState
|
||||
import com.planify.mobile.ui.components.TaskRow
|
||||
|
||||
@Composable
|
||||
fun InboxScreen(
|
||||
onTaskClick: (Task) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: InboxViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
state.tasks.isEmpty() && state.overdueTasks.isEmpty() ->
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.Inbox,
|
||||
title = "Inbox vide",
|
||||
subtitle = "Créez une tâche avec le bouton +",
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
else -> LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
if (state.overdueTasks.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "En retard",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(state.overdueTasks, key = { it.id }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { viewModel.toggleTask(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
|
||||
}
|
||||
|
||||
items(state.tasks, key = { it.id }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { viewModel.toggleTask(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.planify.mobile.ui.inbox
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.domain.repository.TaskRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class InboxUiState(
|
||||
val tasks: List<Task> = emptyList(),
|
||||
val overdueTasks: List<Task> = emptyList(),
|
||||
val showCompleted: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class InboxViewModel @Inject constructor(
|
||||
private val taskRepository: TaskRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState = combine(
|
||||
taskRepository.getInboxTasks(),
|
||||
taskRepository.getOverdueTasks(),
|
||||
) { tasks, overdue ->
|
||||
InboxUiState(tasks = tasks, overdueTasks = overdue)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = InboxUiState(isLoading = true),
|
||||
)
|
||||
|
||||
fun toggleTask(task: Task) {
|
||||
viewModelScope.launch {
|
||||
taskRepository.checkTask(task.id, !task.checked)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteTask(task: Task) {
|
||||
viewModelScope.launch {
|
||||
taskRepository.deleteTask(task.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.planify.mobile.ui.navigation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.planify.mobile.domain.model.Project
|
||||
import com.planify.mobile.domain.repository.ProjectRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DrawerViewModel @Inject constructor(
|
||||
projectRepository: ProjectRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val projects = projectRepository.getAllProjects()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val favorites = projectRepository.getFavoriteProjects()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList<Project>())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.planify.mobile.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.planify.mobile.ui.inbox.InboxScreen
|
||||
import com.planify.mobile.ui.project.ProjectScreen
|
||||
import com.planify.mobile.ui.today.TodayScreen
|
||||
|
||||
@Composable
|
||||
fun PlanifyNavHost(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Route.Inbox.path,
|
||||
modifier = modifier,
|
||||
) {
|
||||
composable(Route.Inbox.path) {
|
||||
InboxScreen(
|
||||
onTaskClick = { /* TODO #11 : ouvrir édition */ }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Route.Today.path) {
|
||||
TodayScreen(
|
||||
onTaskClick = { /* TODO #11 : ouvrir édition */ }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Route.Project().path,
|
||||
arguments = listOf(navArgument("projectId") { type = NavType.StringType })
|
||||
) { backStack ->
|
||||
val projectId = backStack.arguments?.getString("projectId") ?: return@composable
|
||||
ProjectScreen(
|
||||
projectId = projectId,
|
||||
onTaskClick = { /* TODO #11 : ouvrir édition */ },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.planify.mobile.ui.navigation
|
||||
|
||||
sealed class Route(val path: String) {
|
||||
data object Inbox : Route("inbox")
|
||||
data object Today : Route("today")
|
||||
data object Scheduled : Route("scheduled")
|
||||
data class Project(val projectId: String = "{projectId}") :
|
||||
Route("project/{projectId}") {
|
||||
fun buildRoute(id: String) = "project/$id"
|
||||
}
|
||||
data class Label(val labelId: String = "{labelId}") :
|
||||
Route("label/{labelId}") {
|
||||
fun buildRoute(id: String) = "label/$id"
|
||||
}
|
||||
data object Settings : Route("settings")
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.planify.mobile.ui.project
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.FolderOpen
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.domain.model.ViewStyle
|
||||
import com.planify.mobile.ui.components.EmptyState
|
||||
import com.planify.mobile.ui.components.SectionHeader
|
||||
import com.planify.mobile.ui.components.TaskRow
|
||||
|
||||
@Composable
|
||||
fun ProjectScreen(
|
||||
projectId: String,
|
||||
onTaskClick: (Task) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ProjectViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
|
||||
|
||||
if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) {
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.FolderOpen,
|
||||
title = "Projet vide",
|
||||
subtitle = "Créez votre première tâche avec le bouton +",
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
when (state.viewStyle) {
|
||||
ViewStyle.LIST -> ProjectListView(
|
||||
state = state,
|
||||
collapsedSections = collapsedSections.value,
|
||||
onToggleSection = { key ->
|
||||
collapsedSections.value = collapsedSections.value.let {
|
||||
if (it.contains(key)) it - key else it + key
|
||||
}
|
||||
},
|
||||
onTaskClick = onTaskClick,
|
||||
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
||||
modifier = modifier,
|
||||
)
|
||||
ViewStyle.BOARD -> ProjectBoardView(
|
||||
state = state,
|
||||
onTaskClick = onTaskClick,
|
||||
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectListView(
|
||||
state: ProjectUiState,
|
||||
collapsedSections: Set<String>,
|
||||
onToggleSection: (String) -> Unit,
|
||||
onTaskClick: (Task) -> Unit,
|
||||
onCheckedChange: (Task) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
state.sections.forEach { group ->
|
||||
val key = group.section?.id ?: "unsectioned"
|
||||
val name = group.section?.name ?: "Sans section"
|
||||
|
||||
item(key = "header_$key") {
|
||||
SectionHeader(
|
||||
name = name,
|
||||
taskCount = group.tasks.size,
|
||||
collapsed = key in collapsedSections,
|
||||
onToggleCollapse = { onToggleSection(key) },
|
||||
onAddTask = {},
|
||||
)
|
||||
}
|
||||
|
||||
if (key !in collapsedSections) {
|
||||
items(group.tasks, key = { it.id }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { onCheckedChange(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectBoardView(
|
||||
state: ProjectUiState,
|
||||
onTaskClick: (Task) -> Unit,
|
||||
onCheckedChange: (Task) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
) {
|
||||
items(state.sections) { group ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillParentMaxHeight()
|
||||
.padding(horizontal = 4.dp)
|
||||
.fillMaxWidth(0.75f),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = group.section?.name ?: "Sans section",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
LazyColumn {
|
||||
items(group.tasks, key = { it.id }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { onCheckedChange(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.planify.mobile.ui.project
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.planify.mobile.domain.model.Project
|
||||
import com.planify.mobile.domain.model.Section
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.domain.model.ViewStyle
|
||||
import com.planify.mobile.domain.repository.ProjectRepository
|
||||
import com.planify.mobile.domain.repository.SectionRepository
|
||||
import com.planify.mobile.domain.repository.TaskRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SectionWithTasks(val section: Section?, val tasks: List<Task>)
|
||||
|
||||
data class ProjectUiState(
|
||||
val project: Project? = null,
|
||||
val sections: List<SectionWithTasks> = emptyList(),
|
||||
val viewStyle: ViewStyle = ViewStyle.LIST,
|
||||
val isLoading: Boolean = true,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ProjectViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val taskRepository: TaskRepository,
|
||||
private val projectRepository: ProjectRepository,
|
||||
private val sectionRepository: SectionRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val projectId: String = checkNotNull(savedStateHandle["projectId"])
|
||||
|
||||
val uiState = combine(
|
||||
projectRepository.getAllProjects(),
|
||||
sectionRepository.getSectionsByProject(projectId),
|
||||
taskRepository.getTasksByProject(projectId),
|
||||
) { projects, sections, tasks ->
|
||||
val project = projects.find { it.id == projectId }
|
||||
val unsectioned = tasks.filter { it.sectionId == null }
|
||||
val sectionedGroups = sections.map { section ->
|
||||
SectionWithTasks(
|
||||
section = section,
|
||||
tasks = tasks.filter { it.sectionId == section.id },
|
||||
)
|
||||
}
|
||||
val allGroups = buildList {
|
||||
if (unsectioned.isNotEmpty()) add(SectionWithTasks(null, unsectioned))
|
||||
addAll(sectionedGroups)
|
||||
}
|
||||
ProjectUiState(
|
||||
project = project,
|
||||
sections = allGroups,
|
||||
viewStyle = project?.viewStyle ?: ViewStyle.LIST,
|
||||
isLoading = false,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = ProjectUiState(),
|
||||
)
|
||||
|
||||
fun toggleTask(task: Task) {
|
||||
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.planify.mobile.ui.today
|
||||
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Today
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.ui.components.EmptyState
|
||||
import com.planify.mobile.ui.components.SectionHeader
|
||||
import com.planify.mobile.ui.components.TaskRow
|
||||
|
||||
@Composable
|
||||
fun TodayScreen(
|
||||
onTaskClick: (Task) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TodayViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
|
||||
|
||||
if (state.totalCount == 0 && !state.isLoading) {
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.Today,
|
||||
title = "Rien pour aujourd'hui",
|
||||
subtitle = "Profitez de votre journée !",
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
if (state.overdueTasks.isNotEmpty()) {
|
||||
item {
|
||||
SectionHeader(
|
||||
name = "En retard",
|
||||
taskCount = state.overdueTasks.size,
|
||||
collapsed = "overdue" in collapsedSections.value,
|
||||
onToggleCollapse = {
|
||||
collapsedSections.value = collapsedSections.value.toggle("overdue")
|
||||
},
|
||||
onAddTask = {},
|
||||
)
|
||||
}
|
||||
if ("overdue" !in collapsedSections.value) {
|
||||
items(state.overdueTasks, key = { "overdue_${it.id}" }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { viewModel.toggleTask(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
|
||||
}
|
||||
|
||||
state.tasksByProject.forEach { (projectName, tasks) ->
|
||||
item(key = "header_$projectName") {
|
||||
SectionHeader(
|
||||
name = projectName,
|
||||
taskCount = tasks.size,
|
||||
collapsed = projectName in collapsedSections.value,
|
||||
onToggleCollapse = {
|
||||
collapsedSections.value = collapsedSections.value.toggle(projectName)
|
||||
},
|
||||
onAddTask = {},
|
||||
)
|
||||
}
|
||||
if (projectName !in collapsedSections.value) {
|
||||
items(tasks, key = { it.id }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { viewModel.toggleTask(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Set<String>.toggle(key: String) =
|
||||
if (contains(key)) minus(key) else plus(key)
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.planify.mobile.ui.today
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.domain.repository.ProjectRepository
|
||||
import com.planify.mobile.domain.repository.TaskRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TodayUiState(
|
||||
val tasksByProject: Map<String, List<Task>> = emptyMap(),
|
||||
val overdueTasks: List<Task> = emptyList(),
|
||||
val totalCount: Int = 0,
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TodayViewModel @Inject constructor(
|
||||
private val taskRepository: TaskRepository,
|
||||
private val projectRepository: ProjectRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState = combine(
|
||||
taskRepository.getTodayTasks(),
|
||||
taskRepository.getOverdueTasks(),
|
||||
projectRepository.getAllProjects(),
|
||||
) { today, overdue, projects ->
|
||||
val projectMap = projects.associateBy { it.id }
|
||||
val grouped = today
|
||||
.groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId }
|
||||
TodayUiState(
|
||||
tasksByProject = grouped,
|
||||
overdueTasks = overdue,
|
||||
totalCount = today.size + overdue.size,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = TodayUiState(isLoading = true),
|
||||
)
|
||||
|
||||
fun toggleTask(task: Task) {
|
||||
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user