diff --git a/app/src/main/java/com/planify/mobile/data/repository/ProjectRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/ProjectRepositoryImpl.kt new file mode 100644 index 0000000..19a81a0 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/repository/ProjectRepositoryImpl.kt @@ -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> = + dao.getAllProjects().map { list -> list.map { it.toDomain() } } + + override fun getFavoriteProjects(): Flow> = + dao.getFavoriteProjects().map { list -> list.map { it.toDomain() } } + + override fun getSubProjects(parentId: String): Flow> = + 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, + ) +} diff --git a/app/src/main/java/com/planify/mobile/data/repository/SectionRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/SectionRepositoryImpl.kt new file mode 100644 index 0000000..1466d34 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/repository/SectionRepositoryImpl.kt @@ -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> = + 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, + ) +} diff --git a/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt new file mode 100644 index 0000000..d049947 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt @@ -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> = + dao.getTasksByProject(projectId).map { it.map { e -> e.toDomain() } } + + override fun getTasksBySection(sectionId: String): Flow> = + dao.getTasksBySection(sectionId).map { it.map { e -> e.toDomain() } } + + override fun getInboxTasks(): Flow> = + dao.getInboxTasks().map { it.map { e -> e.toDomain() } } + + override fun getTodayTasks(): Flow> = + dao.getTodayTasks().map { it.map { e -> e.toDomain() } } + + override fun getOverdueTasks(): Flow> = + dao.getOverdueTasks().map { it.map { e -> e.toDomain() } } + + override fun getSubTasks(parentId: String): Flow> = + 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(it) }.getOrNull() }, + deadlineDate = deadlineDate, + labels = runCatching { Json.decodeFromString>(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, + ) +} diff --git a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt new file mode 100644 index 0000000..0eb6edd --- /dev/null +++ b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt @@ -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 +} diff --git a/app/src/main/java/com/planify/mobile/domain/repository/SectionRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/SectionRepository.kt new file mode 100644 index 0000000..70dc55b --- /dev/null +++ b/app/src/main/java/com/planify/mobile/domain/repository/SectionRepository.kt @@ -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> + suspend fun getSectionById(id: String): Section? + suspend fun insertSection(section: Section) + suspend fun updateSection(section: Section) + suspend fun deleteSection(id: String) +} diff --git a/app/src/main/java/com/planify/mobile/ui/MainActivity.kt b/app/src/main/java/com/planify/mobile/ui/MainActivity.kt index f866341..b2fe2d3 100644 --- a/app/src/main/java/com/planify/mobile/ui/MainActivity.kt +++ b/app/src/main/java/com/planify/mobile/ui/MainActivity.kt @@ -15,7 +15,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { PlanifyTheme { - // TODO #6 : PlanifyNavHost() + MainScreen() } } } diff --git a/app/src/main/java/com/planify/mobile/ui/MainScreen.kt b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt new file mode 100644 index 0000000..48ded9f --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/components/DueDateChip.kt b/app/src/main/java/com/planify/mobile/ui/components/DueDateChip.kt new file mode 100644 index 0000000..8221f69 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/components/DueDateChip.kt @@ -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()) +} diff --git a/app/src/main/java/com/planify/mobile/ui/components/EmptyState.kt b/app/src/main/java/com/planify/mobile/ui/components/EmptyState.kt new file mode 100644 index 0000000..77f70ca --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/components/EmptyState.kt @@ -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 +", + ) +} diff --git a/app/src/main/java/com/planify/mobile/ui/components/LabelChip.kt b/app/src/main/java/com/planify/mobile/ui/components/LabelChip.kt new file mode 100644 index 0000000..d52b8ef --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/components/LabelChip.kt @@ -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)) +} diff --git a/app/src/main/java/com/planify/mobile/ui/components/PriorityBadge.kt b/app/src/main/java/com/planify/mobile/ui/components/PriorityBadge.kt new file mode 100644 index 0000000..67a7cd9 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/components/PriorityBadge.kt @@ -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) +} diff --git a/app/src/main/java/com/planify/mobile/ui/components/SectionHeader.kt b/app/src/main/java/com/planify/mobile/ui/components/SectionHeader.kt new file mode 100644 index 0000000..478d70c --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/components/SectionHeader.kt @@ -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 = {}, + ) +} diff --git a/app/src/main/java/com/planify/mobile/ui/components/TaskRow.kt b/app/src/main/java/com/planify/mobile/ui/components/TaskRow.kt new file mode 100644 index 0000000..83c9750 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/components/TaskRow.kt @@ -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 = {}, + ) + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/inbox/InboxScreen.kt b/app/src/main/java/com/planify/mobile/ui/inbox/InboxScreen.kt new file mode 100644 index 0000000..15231e1 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/inbox/InboxScreen.kt @@ -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) }, + ) + } + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/inbox/InboxViewModel.kt b/app/src/main/java/com/planify/mobile/ui/inbox/InboxViewModel.kt new file mode 100644 index 0000000..f9a1487 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/inbox/InboxViewModel.kt @@ -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 = emptyList(), + val overdueTasks: List = 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) + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/navigation/DrawerViewModel.kt b/app/src/main/java/com/planify/mobile/ui/navigation/DrawerViewModel.kt new file mode 100644 index 0000000..f3d870f --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/navigation/DrawerViewModel.kt @@ -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()) +} diff --git a/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt new file mode 100644 index 0000000..bbd9e2e --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt @@ -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() }, + ) + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt b/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt new file mode 100644 index 0000000..aae7359 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt @@ -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") +} diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt new file mode 100644 index 0000000..a4c3752 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt @@ -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()) } + + 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, + 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) }, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt new file mode 100644 index 0000000..8eb532d --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt @@ -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) + +data class ProjectUiState( + val project: Project? = null, + val sections: List = 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) } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/today/TodayScreen.kt b/app/src/main/java/com/planify/mobile/ui/today/TodayScreen.kt new file mode 100644 index 0000000..dbe90d3 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/today/TodayScreen.kt @@ -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()) } + + 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.toggle(key: String) = + if (contains(key)) minus(key) else plus(key) diff --git a/app/src/main/java/com/planify/mobile/ui/today/TodayViewModel.kt b/app/src/main/java/com/planify/mobile/ui/today/TodayViewModel.kt new file mode 100644 index 0000000..a3f105e --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/today/TodayViewModel.kt @@ -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> = emptyMap(), + val overdueTasks: List = 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) } + } +}