From d74524b9c9c8f2a38bfe3ee6e7fa8adcd874d2d3 Mon Sep 17 00:00:00 2001 From: esraaehab333 Date: Tue, 11 Aug 2026 13:14:21 +0300 Subject: [PATCH 1/5] fix : the goal ui --- app/src/main/java/com/awan/app/AwanApp.kt | 6 +- .../awan/app/core/data/goal/GoalMappers.kt | 2 + .../app/core/data/goal/GoalRepositoryImpl.kt | 110 +++- .../awan/app/core/data/task/TaskMappers.kt | 13 + .../com/awan/app/core/database/dao/GoalDao.kt | 2 - .../com/awan/app/core/database/dao/TaskDao.kt | 11 +- .../domain/goal/usecase/CreateGoalUseCase.kt | 16 + .../domain/goal/usecase/DeleteGoalUseCase.kt | 11 + .../domain/goal/usecase/GetGoalUseCase.kt | 12 + .../kotlin/com/awan/app/core/model/Goal.kt | 1 + feature/goals/impl/build.gradle.kts | 1 + .../impl/navigation/GoalsEntryProvider.kt | 135 +++-- .../goals/impl/presentation/GoalDetailsMvi.kt | 14 + .../impl/presentation/GoalDetailsViewModel.kt | 49 ++ .../goals/impl/presentation/GoalsMvi.kt | 46 +- .../goals/impl/presentation/GoalsViewModel.kt | 16 +- .../goals/impl/presentation/InboxMvi.kt | 3 + .../goals/impl/presentation/InboxScreen.kt | 176 +++++-- .../goals/impl/presentation/InboxViewModel.kt | 4 + .../goals/impl/ui/GoalDetailsScreen.kt | 480 ++++++++++++++++++ .../feature/goals/impl/ui/GoalsEmptyState.kt | 22 +- .../awan/feature/goals/impl/ui/GoalsScreen.kt | 94 ++-- .../goals/impl/ui/components/GoalCard.kt | 383 ++++++++------ .../goals/impl/ui/components/GoalColors.kt | 4 - .../goals/impl/ui/components/GoalTaskRow.kt | 62 --- .../impl/ui/components/GoalsMascotHeader.kt | 55 -- .../impl/ui/components/GoalsSearchBar.kt | 114 +++++ .../goals/impl/ui/components/GoalsTabRow.kt | 92 ---- .../impl/src/main/res/values-ar/strings.xml | 16 +- .../impl/src/main/res/values/strings.xml | 17 +- 30 files changed, 1396 insertions(+), 571 deletions(-) create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/CreateGoalUseCase.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/DeleteGoalUseCase.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/GetGoalUseCase.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt delete mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalTaskRow.kt delete mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsMascotHeader.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt delete mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsTabRow.kt diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt index 3c0de9e2..fab1c6a1 100644 --- a/app/src/main/java/com/awan/app/AwanApp.kt +++ b/app/src/main/java/com/awan/app/AwanApp.kt @@ -61,6 +61,7 @@ import com.awan.feature.auth.impl.navigation.authEntry import com.awan.feature.calendar.impl.navigation.calendarEntry import com.awan.feature.chat.impl.navigation.chatEntry import com.awan.feature.goals.api.GoalsRoute +import com.awan.feature.goals.api.GoalDetailsRoute import com.awan.feature.goals.impl.navigation.goalsEntry import com.awan.feature.home.api.HomeRoute import com.awan.feature.home.impl.navigation.homeEntry @@ -242,7 +243,10 @@ fun AwanApp( onBack = { navigator.goBack() }, ) chatEntry() - goalsEntry() + goalsEntry( + onNavigateToGoalDetails = { id -> navigator.navigate(com.awan.feature.goals.api.GoalDetailsRoute(id)) }, + onBack = { navigator.goBack() } + ) aiTasksEntry(onBack = { navigator.goBack() }) inventoryEntry(onBack = { navigator.goBack() }) profileEntry( diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalMappers.kt b/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalMappers.kt index 487d009c..bc9325cc 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalMappers.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalMappers.kt @@ -50,6 +50,7 @@ internal fun GoalInfoResponse.toModel(): Goal { emoji = extractedEmoji, status = status.toModel(), tasks = tasks.map { it.toTaskModel() }, + targetDate = targetDate, ) } @@ -68,6 +69,7 @@ internal fun GoalEntity.toModel(): Goal { emoji = extractedEmoji, status = goalStatus, tasks = emptyList(), // tasks are stored separately in TaskEntity + targetDate = targetDate, ) } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt index fc019659..1f78eae4 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt @@ -1,10 +1,13 @@ package com.awan.app.core.data.goal +import com.awan.app.core.common.dispatcher.AwanDispatchers +import com.awan.app.core.common.dispatcher.Dispatcher import com.awan.app.core.common.error.AppError import com.awan.app.core.common.result.Result import com.awan.app.core.common.result.map import com.awan.app.core.data.goal.remote.GoalRemoteDataSource import com.awan.app.core.database.dao.GoalDao +import com.awan.app.core.database.dao.TaskDao import com.awan.app.core.domain.goal.repository.GoalRepository import com.awan.app.core.domain.network.NetworkConnectivityMonitor import com.awan.app.core.model.Goal @@ -16,6 +19,11 @@ import com.awan.app.core.network.dto.GoalDecomposeRequest import com.awan.app.core.network.dto.goal.ConfirmAiScheduleRequest import com.awan.app.core.network.dto.goal.CreateGoalRequest import com.awan.app.core.network.dto.goal.ProposedGoalSessionDto +import com.awan.app.core.data.sync.SyncTtl +import com.awan.app.core.data.task.toEntity +import com.awan.app.core.data.task.toTaskModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -29,55 +37,111 @@ import javax.inject.Inject class GoalRepositoryImpl @Inject constructor( private val remoteDataSource: GoalRemoteDataSource, private val goalDao: GoalDao, + private val taskDao: TaskDao, private val connectivityMonitor: NetworkConnectivityMonitor, + @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : GoalRepository { - override suspend fun getGoals(): Result> { + override suspend fun getGoals(): Result> = withContext(ioDispatcher) { + if (connectivityMonitor.isCurrentlyOnline()) { + val result = remoteDataSource.getGoals() + if (result is Result.Success) { + val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) + goalDao.upsertGoals(result.data.map { it.toEntity().copy(expiryTime = expiry) }) + + // Also cache tasks if provided + result.data.forEach { goalDto -> + if (goalDto.tasks.isNotEmpty()) { + taskDao.upsertTasks(goalDto.tasks.map { it.toEntity(expiryTime = expiry) }) + } + } + } + } val entities = goalDao.getAllGoals() - return Result.Success(entities.map { it.toModel() }) + val models = entities.map { entity -> + val tasks = taskDao.getTasksByGoal(entity.id).map { it.toTaskModel() } + entity.toModel().copy(tasks = tasks) + } + Result.Success(models) } override suspend fun createGoal( title: String, description: String?, targetDate: String?, - ): Result { + ): Result = withContext(ioDispatcher) { if (!connectivityMonitor.isCurrentlyOnline()) { - return Result.Error(AppError.Network) + return@withContext Result.Error(AppError.Network) } - return remoteDataSource.createGoal( + remoteDataSource.createGoal( CreateGoalRequest( title = title, description = description, targetDate = targetDate, ), ).map { dto -> - val entity = dto.toEntity() + val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) + val entity = dto.toEntity().copy(expiryTime = expiry) goalDao.upsertGoal(entity) - entity.toModel() + if (dto.tasks.isNotEmpty()) { + taskDao.upsertTasks(dto.tasks.map { it.toEntity(expiryTime = expiry) }) + } + entity.toModel().copy(tasks = dto.tasks.map { it.toTaskModel() }) } } - override suspend fun getInboxGoal(): Result { + override suspend fun getInboxGoal(): Result = withContext(ioDispatcher) { + if (connectivityMonitor.isCurrentlyOnline()) { + val result = remoteDataSource.getInboxGoal() + if (result is Result.Success) { + val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) + goalDao.upsertGoal(result.data.toEntity().copy(expiryTime = expiry)) + if (result.data.tasks.isNotEmpty()) { + taskDao.upsertTasks(result.data.tasks.map { it.toEntity(expiryTime = expiry) }) + } + } + } val cached = goalDao.getAllGoals().find { it.isInbox } if (cached != null) { - return Result.Success(cached.toModel()) + val tasks = taskDao.getTasksByGoal(cached.id).map { it.toTaskModel() } + return@withContext Result.Success(cached.toModel().copy(tasks = tasks)) } - return Result.Error(AppError.NotFound) + Result.Error(AppError.NotFound) } - override suspend fun getGoal(goalId: String): Result { + override suspend fun getGoal(goalId: String): Result = withContext(ioDispatcher) { + if (connectivityMonitor.isCurrentlyOnline()) { + val result = remoteDataSource.getGoal(goalId) + if (result is Result.Success) { + val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) + goalDao.upsertGoal(result.data.toEntity().copy(expiryTime = expiry)) + if (result.data.tasks.isNotEmpty()) { + taskDao.upsertTasks(result.data.tasks.map { it.toEntity(expiryTime = expiry) }) + } + } + } val entity = goalDao.getGoal(goalId) - if (entity != null) return Result.Success(entity.toModel()) - return Result.Error(AppError.NotFound) + if (entity != null) { + val tasks = taskDao.getTasksByGoal(goalId).map { it.toTaskModel() } + return@withContext Result.Success(entity.toModel().copy(tasks = tasks)) + } + Result.Error(AppError.NotFound) } - override suspend fun deleteGoal(goalId: String): Result { + override suspend fun deleteGoal(goalId: String): Result = withContext(ioDispatcher) { if (!connectivityMonitor.isCurrentlyOnline()) { - return Result.Error(AppError.Network) + return@withContext Result.Error(AppError.Network) + } + + // Restriction: Inbox cannot be deleted + val existing = goalDao.getGoal(goalId) + if (existing?.isInbox == true) { + return@withContext Result.Error(AppError.Unknown(Throwable("Inbox goal cannot be deleted"))) } - return remoteDataSource.deleteGoal(goalId).map { + + remoteDataSource.deleteGoal(goalId).map { goalDao.deleteGoal(goalId) + taskDao.deleteTasksByGoal(goalId) } } @@ -93,14 +157,18 @@ class GoalRepositoryImpl @Inject constructor( ).map { it.toDecompositionReply() } } - override suspend fun confirmDecomposition(sessionId: String): Result { + override suspend fun confirmDecomposition(sessionId: String): Result = withContext(ioDispatcher) { if (!connectivityMonitor.isCurrentlyOnline()) { - return Result.Error(AppError.Network) + return@withContext Result.Error(AppError.Network) } - return remoteDataSource.confirmDecomposition(sessionId).map { dto -> - val entity = dto.toEntity() + remoteDataSource.confirmDecomposition(sessionId).map { dto -> + val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) + val entity = dto.toEntity().copy(expiryTime = expiry) goalDao.upsertGoal(entity) - entity.toModel() + if (dto.tasks.isNotEmpty()) { + taskDao.upsertTasks(dto.tasks.map { it.toEntity(expiryTime = expiry) }) + } + entity.toModel().copy(tasks = dto.tasks.map { it.toTaskModel() }) } } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/task/TaskMappers.kt b/core/data/src/main/kotlin/com/awan/app/core/data/task/TaskMappers.kt index 39d76d62..a81ffcce 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/task/TaskMappers.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/task/TaskMappers.kt @@ -69,6 +69,19 @@ internal fun TaskInfoResponse.toTaskModel(): Task = Task( category = category?.toModel(), ) +internal fun com.awan.app.core.database.model.TaskEntity.toTaskModel(): Task = Task( + id = id, + title = title, + description = description, + estimatedDurationMinutes = estimatedDuration, + status = status.toTaskStatus(), + mandatory = mandatory, + estimatedPoints = estimatedPoints, + allowTaskSplitting = allowTaskSplitting, + goalId = goalId, + dependsOnTaskIds = emptyList(), // Dependencies are stored separately in Room +) + internal fun TaskProposalResponse.toModel(): TaskProposals = TaskProposals( sourceSummary = sourceSummary, tasks = tasks.map { it.toModel() }, diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/dao/GoalDao.kt b/core/database/src/main/kotlin/com/awan/app/core/database/dao/GoalDao.kt index 6001deda..5a90ff4e 100644 --- a/core/database/src/main/kotlin/com/awan/app/core/database/dao/GoalDao.kt +++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/GoalDao.kt @@ -15,8 +15,6 @@ interface GoalDao { @Query("SELECT * FROM goals WHERE status = :status ORDER BY createdAt DESC") fun observeGoalsByStatus(status: String): Flow> @Query("SELECT * FROM goals WHERE id = :goalId") fun observeGoal(goalId: String): Flow @Query("SELECT * FROM goals WHERE id = :goalId") suspend fun getGoal(goalId: String): GoalEntity? - @Query("SELECT * FROM goals WHERE isInbox = 1 LIMIT 1") fun observeInboxGoal(): Flow @Query("DELETE FROM goals WHERE id = :goalId") suspend fun deleteGoal(goalId: String) - @Query("SELECT id FROM goals WHERE status = 'ACTIVE' AND isInbox = 0") suspend fun getActiveNonInboxGoalIds(): List @Query("SELECT MIN(expiryTime) FROM goals") suspend fun getMinExpiryTime(): Long? } diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/dao/TaskDao.kt b/core/database/src/main/kotlin/com/awan/app/core/database/dao/TaskDao.kt index d918747f..49cd35f6 100644 --- a/core/database/src/main/kotlin/com/awan/app/core/database/dao/TaskDao.kt +++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/TaskDao.kt @@ -24,15 +24,8 @@ interface TaskDao { @Query("SELECT * FROM tasks WHERE goalId = :goalId") fun observeTasksByGoal(goalId: String): Flow> - /** Observe all Inbox tasks (goalId IS NULL). */ - @Query("SELECT * FROM tasks WHERE goalId IS NULL") - fun observeInboxTasks(): Flow> - - @Query("SELECT * FROM tasks") - fun observeAllTasks(): Flow> - - @Query("SELECT * FROM tasks") - suspend fun getAllTasks(): List + @Query("SELECT * FROM tasks WHERE goalId = :goalId") + suspend fun getTasksByGoal(goalId: String): List @Query("SELECT * FROM tasks WHERE id = :taskId") fun observeTask(taskId: String): Flow diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/CreateGoalUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/CreateGoalUseCase.kt new file mode 100644 index 00000000..739bb9f6 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/CreateGoalUseCase.kt @@ -0,0 +1,16 @@ +package com.awan.app.core.domain.goal.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.goal.repository.GoalRepository +import com.awan.app.core.model.Goal +import javax.inject.Inject + +class CreateGoalUseCase @Inject constructor( + private val repository: GoalRepository, +) { + suspend operator fun invoke( + title: String, + description: String? = null, + targetDate: String? = null, + ): Result = repository.createGoal(title, description, targetDate) +} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/DeleteGoalUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/DeleteGoalUseCase.kt new file mode 100644 index 00000000..0e3a4be7 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/DeleteGoalUseCase.kt @@ -0,0 +1,11 @@ +package com.awan.app.core.domain.goal.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.goal.repository.GoalRepository +import javax.inject.Inject + +class DeleteGoalUseCase @Inject constructor( + private val repository: GoalRepository, +) { + suspend operator fun invoke(goalId: String): Result = repository.deleteGoal(goalId) +} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/GetGoalUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/GetGoalUseCase.kt new file mode 100644 index 00000000..d866b770 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/GetGoalUseCase.kt @@ -0,0 +1,12 @@ +package com.awan.app.core.domain.goal.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.goal.repository.GoalRepository +import com.awan.app.core.model.Goal +import javax.inject.Inject + +class GetGoalUseCase @Inject constructor( + private val repository: GoalRepository, +) { + suspend operator fun invoke(goalId: String): Result = repository.getGoal(goalId) +} diff --git a/core/model/src/main/kotlin/com/awan/app/core/model/Goal.kt b/core/model/src/main/kotlin/com/awan/app/core/model/Goal.kt index 3ea38e2e..44944dd6 100644 --- a/core/model/src/main/kotlin/com/awan/app/core/model/Goal.kt +++ b/core/model/src/main/kotlin/com/awan/app/core/model/Goal.kt @@ -7,6 +7,7 @@ data class Goal( val emoji: String, val status: GoalStatus = GoalStatus.ACTIVE, val tasks: List = emptyList(), + val targetDate: String? = null, ) { val totalTasks: Int get() = tasks.size diff --git a/feature/goals/impl/build.gradle.kts b/feature/goals/impl/build.gradle.kts index 83f07a81..ec90696c 100644 --- a/feature/goals/impl/build.gradle.kts +++ b/feature/goals/impl/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { implementation(project(":feature:goals:api")) + implementation(project(":feature:add-task")) implementation(project(":core:domain")) implementation(project(":core:design-system")) diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt index 4c73a458..6193fd4a 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt @@ -2,16 +2,21 @@ package com.awan.feature.goals.impl.navigation import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -35,16 +40,51 @@ import com.awan.app.core.designsystem.AwanText import com.awan.app.core.designsystem.AwanTheme import com.awan.core.navigation.Route import com.awan.feature.goals.api.GoalsRoute +import com.awan.feature.goals.api.GoalDetailsRoute +import androidx.compose.material3.Icon import androidx.compose.ui.graphics.Brush import com.awan.feature.goals.impl.R import com.awan.feature.goals.impl.ui.GoalsScreen import com.awan.feature.goals.impl.presentation.GoalsViewModel import com.awan.feature.goals.impl.presentation.InboxScreen import com.awan.feature.goals.impl.presentation.InboxViewModel +import com.awan.feature.goals.impl.presentation.GoalDetailsViewModel +import com.awan.feature.goals.impl.ui.GoalDetailsScreen +import com.awan.feature.goals.impl.presentation.GoalDetailsAction +import com.awan.feature.goals.impl.presentation.GoalsAction +import com.awan.app.core.designsystem.ObserveAsEvents +import com.awan.feature.goals.impl.presentation.GoalsEvent +import com.composables.icons.lucide.Inbox +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Target -fun EntryProviderScope.goalsEntry() { +fun EntryProviderScope.goalsEntry( + onNavigateToGoalDetails: (String) -> Unit = {}, + onBack: () -> Unit = {}, +) { entry { - GoalsRouteScreen() + GoalsRouteScreen( + onNavigateToGoalDetails = onNavigateToGoalDetails, + ) + } + + entry { route -> + val viewModel: GoalDetailsViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + androidx.compose.runtime.LaunchedEffect(route.id) { + viewModel.loadGoal(route.id) + } + + GoalDetailsScreen( + state = state, + onAction = { action -> + when (action) { + GoalDetailsAction.Back -> onBack() + else -> viewModel.onAction(action) + } + } + ) } } @@ -54,11 +94,19 @@ enum class GoalsTopLevelTab { @Composable fun GoalsRouteScreen( + onNavigateToGoalDetails: (String) -> Unit, goalsViewModel: GoalsViewModel = hiltViewModel(), inboxViewModel: InboxViewModel = hiltViewModel(), ) { val goalsState by goalsViewModel.state.collectAsStateWithLifecycle() val inboxState by inboxViewModel.state.collectAsStateWithLifecycle() + + ObserveAsEvents(goalsViewModel.events) { event -> + when (event) { + is GoalsEvent.NavigateToGoalDetails -> + onNavigateToGoalDetails(event.goalId) + } + } var selectedTab by rememberSaveable { mutableStateOf(GoalsTopLevelTab.Goals) } @@ -84,7 +132,7 @@ fun GoalsRouteScreen( contentAlignment = Alignment.Center, ) { val titleText = when (selectedTab) { - GoalsTopLevelTab.Goals -> stringResource(R.string.goals_title) + GoalsTopLevelTab.Goals -> stringResource(R.string.goals_title_count) GoalsTopLevelTab.Inbox -> stringResource(R.string.inbox_title) } AwanText( @@ -100,44 +148,63 @@ fun GoalsRouteScreen( Spacer(modifier = Modifier.height(12.dp)) // Top Level Segmented Control - val shape = RoundedCornerShape(12.dp) - Row( + val reduced = com.awan.app.core.designsystem.reducedMotion() + val slide by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (selectedTab == GoalsTopLevelTab.Goals) 0f else 1f, + animationSpec = if (reduced) androidx.compose.animation.core.snap() else AwanTheme.motion.settle.spec(), + label = "tabSlide", + ) + + androidx.compose.foundation.layout.BoxWithConstraints( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .clip(shape) - .background(AwanTheme.colors.surface) - .border(1.dp, AwanTheme.colors.line, shape) + .height(46.dp) + .clip(AwanTheme.shapes.pill) + .background(AwanTheme.colors.disabledSurface) .padding(4.dp), ) { - GoalsTopLevelTab.entries.forEach { tab -> - val isSelected = tab == selectedTab - val tabShape = RoundedCornerShape(8.dp) - val text = when (tab) { - GoalsTopLevelTab.Goals -> stringResource(R.string.goals_title) - GoalsTopLevelTab.Inbox -> stringResource(R.string.inbox_title) - } + val halfWidth = (maxWidth - 8.dp) / 2 + + Box( + modifier = Modifier + .offset { androidx.compose.ui.unit.IntOffset(x = (halfWidth * slide).roundToPx(), y = 0) } + .width(halfWidth) + .fillMaxHeight() + .clip(AwanTheme.shapes.pill) + .background(AwanTheme.colors.surface), + ) - Box( - modifier = Modifier - .weight(1f) - .defaultMinSize(minHeight = 48.dp) - .clip(tabShape) - .background(if (isSelected) AwanTheme.colors.sky else Color.Transparent) - .selectable( - selected = isSelected, - onClick = { selectedTab = tab }, - role = Role.Tab, - ), - contentAlignment = Alignment.Center, - ) { - AwanText( - text = text, - style = AwanTheme.typography.button.copy( - color = if (isSelected) AwanTheme.colors.onSky else AwanTheme.colors.textSecondary, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, - ), + Row(Modifier.fillMaxWidth().fillMaxHeight()) { + GoalsTopLevelTab.entries.forEach { tab -> + val isSelected = tab == selectedTab + val text = when (tab) { + GoalsTopLevelTab.Goals -> stringResource(R.string.goals_title_count) + GoalsTopLevelTab.Inbox -> stringResource(R.string.inbox_title) + } + val contentColor by androidx.compose.animation.animateColorAsState( + targetValue = if (isSelected) AwanTheme.colors.textPrimary else AwanTheme.colors.textSecondary, + animationSpec = AwanTheme.motion.settle.spec(), + label = "tabContent", ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(AwanTheme.shapes.pill) + .selectable( + selected = isSelected, + onClick = { selectedTab = tab }, + role = Role.Tab, + ), + contentAlignment = Alignment.Center, + ) { + AwanText( + text = text, + style = AwanTheme.styles.buttonCompactText.copy(color = contentColor) + ) + } } } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt new file mode 100644 index 00000000..ba1456a7 --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt @@ -0,0 +1,14 @@ +package com.awan.feature.goals.impl.presentation + +import com.awan.app.core.model.Goal + +data class GoalDetailsState( + val isLoading: Boolean = true, + val goal: Goal? = null, + val error: String? = null, +) + +sealed interface GoalDetailsAction { + data object Retry : GoalDetailsAction + data object Back : GoalDetailsAction +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt new file mode 100644 index 00000000..9768a938 --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt @@ -0,0 +1,49 @@ +package com.awan.feature.goals.impl.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.goal.usecase.GetGoalUseCase +import com.awan.app.core.model.Goal +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GoalDetailsViewModel @Inject constructor( + private val getGoalUseCase: GetGoalUseCase, +) : ViewModel() { + + private val _state = MutableStateFlow(GoalDetailsState()) + val state: StateFlow = _state.asStateFlow() + + fun loadGoal(id: String) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + when (val result = getGoalUseCase(id)) { + is Result.Success<*> -> { + val goal = result.data as? Goal + _state.update { it.copy(isLoading = false, goal = goal) } + } + is Result.Error -> { + _state.update { it.copy(isLoading = false, error = "Failed to load goal") } + } + Result.Loading -> {} + } + } + } + + fun onAction(action: GoalDetailsAction) { + when (action) { + GoalDetailsAction.Retry -> { + _state.value.goal?.id?.let { loadGoal(it) } + } + + GoalDetailsAction.Back -> {} + } + } +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsMvi.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsMvi.kt index b48f7f6d..4b063ae6 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsMvi.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsMvi.kt @@ -1,21 +1,47 @@ package com.awan.feature.goals.impl.presentation import com.awan.app.core.model.Goal - -enum class GoalsTab { - Active, - Completed, -} +import com.awan.app.core.model.GoalStatus data class GoalsState( val isLoading: Boolean = true, val isError: Boolean = false, - val tab: GoalsTab = GoalsTab.Active, - val activeGoals: List = emptyList(), - val completedGoals: List = emptyList(), -) + val goals: List = emptyList(), + val searchQuery: String = "", +) { + val activeGoals: List = goals.filter { it.status == GoalStatus.ACTIVE } + + val filteredGoals: List + get() { + val baseList = if (searchQuery.isBlank()) { + activeGoals + } else { + activeGoals.filter { it.title.contains(searchQuery, ignoreCase = true) } + } + // Sort by priority (Must > Should > Could > Wont) + return baseList.sortedBy { it.moscowPriority.ordinal } + } +} + +enum class MoscowPriority { + Must, Should, Could, Wont +} + +val Goal.moscowPriority: MoscowPriority + get() = when { + title.contains("MUST", ignoreCase = true) || title.contains("مهم", ignoreCase = true) -> MoscowPriority.Must + title.contains("SHOULD", ignoreCase = true) -> MoscowPriority.Should + title.contains("COULD", ignoreCase = true) -> MoscowPriority.Could + title.contains("WONT", ignoreCase = true) -> MoscowPriority.Wont + else -> MoscowPriority.Could // Default fantasy drift + } sealed interface GoalsAction { - data class TabSelected(val tab: GoalsTab) : GoalsAction + data class SearchQueryChanged(val query: String) : GoalsAction data object RetryClicked : GoalsAction + data class GoalClicked(val goalId: String) : GoalsAction +} + +sealed interface GoalsEvent { + data class NavigateToGoalDetails(val goalId: String) : GoalsEvent } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt index f1f4799c..3391103b 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt @@ -5,9 +5,11 @@ import androidx.lifecycle.viewModelScope import com.awan.app.core.common.result.Result import com.awan.app.core.domain.goal.usecase.GetGoalsUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,14 +22,22 @@ class GoalsViewModel @Inject constructor( private val _state = MutableStateFlow(GoalsState()) val state: StateFlow = _state.asStateFlow() + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + init { loadGoals() } fun onAction(action: GoalsAction) { when (action) { - is GoalsAction.TabSelected -> _state.update { it.copy(tab = action.tab) } + is GoalsAction.SearchQueryChanged -> _state.update { it.copy(searchQuery = action.query) } GoalsAction.RetryClicked -> loadGoals() + is GoalsAction.GoalClicked -> { + viewModelScope.launch { + _events.send(GoalsEvent.NavigateToGoalDetails(action.goalId)) + } + } } } @@ -37,13 +47,11 @@ class GoalsViewModel @Inject constructor( when (val result = getGoalsUseCase()) { is Result.Success -> { - val goals = result.data _state.update { it.copy( isLoading = false, isError = false, - activeGoals = goals.filter { goal -> !goal.isCompleted }, - completedGoals = goals.filter { goal -> goal.isCompleted }, + goals = result.data, ) } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxMvi.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxMvi.kt index 6dc75766..95d57e74 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxMvi.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxMvi.kt @@ -89,6 +89,7 @@ data class InboxUiState( val expandedTaskId: String? = null, /** Tasks visible after applying search and filter. */ val visibleTasks: List = emptyList(), + val showFilterSheet: Boolean = false, ) sealed interface InboxAction { @@ -96,5 +97,7 @@ sealed interface InboxAction { data class StatusFilterToggled(val filter: InboxTaskDisplayStatus) : InboxAction data class SessionFilterToggled(val filter: InboxSessionFilter) : InboxAction data class TaskExpandToggled(val taskId: String) : InboxAction + data object FilterClicked : InboxAction + data object FilterDismissed : InboxAction data object RetryClicked : InboxAction } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxScreen.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxScreen.kt index a7ca5875..300f9fcd 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxScreen.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -48,7 +47,13 @@ import com.awan.app.core.designsystem.AwanTextField import com.awan.app.core.designsystem.AwanTheme import com.awan.app.core.designsystem.MascotExpression import com.awan.feature.goals.impl.R +import com.awan.feature.goals.impl.ui.components.GoalsSearchBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import com.awan.app.core.designsystem.AwanSurface +@OptIn(ExperimentalMaterial3Api::class) @Composable fun InboxScreen( state: InboxUiState, @@ -56,6 +61,7 @@ fun InboxScreen( modifier: Modifier = Modifier, ) { val colors = AwanTheme.colors + val filterSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) Box( modifier = modifier.fillMaxSize(), @@ -63,60 +69,16 @@ fun InboxScreen( Column( modifier = Modifier.fillMaxSize() ) { - // Search Bar + // Search Bar with Filter Button Box(modifier = Modifier.padding(horizontal = 16.dp)) { - AwanTextField( - value = state.searchQuery, - onValueChange = { onAction(InboxAction.SearchQueryChanged(it)) }, - placeholder = stringResource(R.string.inbox_search_placeholder), + GoalsSearchBar( + query = state.searchQuery, + onQueryChange = { onAction(InboxAction.SearchQueryChanged(it)) }, + onFilterClick = { onAction(InboxAction.FilterClicked) }, modifier = Modifier.fillMaxWidth() ) } - Spacer(modifier = Modifier.height(12.dp)) - - // Filters - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Status Filters - InboxTaskDisplayStatus.entries.forEach { status -> - val isSelected = status in state.activeStatusFilters - val labelRes = when (status) { - InboxTaskDisplayStatus.Drafted -> R.string.inbox_status_drafted - InboxTaskDisplayStatus.Active -> R.string.inbox_status_active - InboxTaskDisplayStatus.Completed -> R.string.inbox_status_completed - InboxTaskDisplayStatus.Cancelled -> R.string.inbox_status_cancelled - InboxTaskDisplayStatus.Missed -> R.string.inbox_status_missed - } - AwanChip( - label = stringResource(labelRes), - active = isSelected, - tone = if (isSelected) AwanChipTone.Sky else AwanChipTone.Neutral, - onClick = { onAction(InboxAction.StatusFilterToggled(status)) } - ) - } - - // Session Filters - InboxSessionFilter.entries.forEach { filter -> - val isSelected = filter in state.activeSessionFilters - val labelRes = when (filter) { - InboxSessionFilter.ActiveNow -> R.string.inbox_filter_active_now - InboxSessionFilter.Missed -> R.string.inbox_filter_missed - } - AwanChip( - label = stringResource(labelRes), - active = isSelected, - tone = if (isSelected) AwanChipTone.Sky else AwanChipTone.Neutral, - onClick = { onAction(InboxAction.SessionFilterToggled(filter)) } - ) - } - } - Spacer(modifier = Modifier.height(16.dp)) // Content @@ -166,6 +128,120 @@ fun InboxScreen( } } } + + // Filter Bottom Sheet + if (state.showFilterSheet) { + ModalBottomSheet( + onDismissRequest = { onAction(InboxAction.FilterDismissed) }, + sheetState = filterSheetState, + containerColor = colors.background, + dragHandle = { + // Standard Awan Drag Handle + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(width = 36.dp, height = 4.dp) + .clip(AwanTheme.shapes.pill) + .background(AwanTheme.colors.line) + ) + } + } + ) { + InboxFilterSheetContent(state = state, onAction = onAction) + } + } + } +} + +@Composable +private fun InboxFilterSheetContent( + state: InboxUiState, + onAction: (InboxAction) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), // Space for system nav + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + AwanText( + text = "Filters", + style = AwanTheme.styles.headingText + ) + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AwanText( + text = "Task Status", + style = AwanTheme.styles.captionText + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InboxTaskDisplayStatus.entries.forEach { status -> + val isSelected = status in state.activeStatusFilters + val labelRes = when (status) { + InboxTaskDisplayStatus.Drafted -> R.string.inbox_status_drafted + InboxTaskDisplayStatus.Active -> R.string.inbox_status_active + InboxTaskDisplayStatus.Completed -> R.string.inbox_status_completed + InboxTaskDisplayStatus.Cancelled -> R.string.inbox_status_cancelled + InboxTaskDisplayStatus.Missed -> R.string.inbox_status_missed + } + AwanChip( + label = stringResource(labelRes), + active = isSelected, + tone = if (isSelected) AwanChipTone.Sky else AwanChipTone.Neutral, + onClick = { onAction(InboxAction.StatusFilterToggled(status)) } + ) + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AwanText( + text = "Time Filters", + style = AwanTheme.styles.captionText + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InboxSessionFilter.entries.forEach { filter -> + val isSelected = filter in state.activeSessionFilters + val labelRes = when (filter) { + InboxSessionFilter.ActiveNow -> R.string.inbox_filter_active_now + InboxSessionFilter.Missed -> R.string.inbox_filter_missed + } + AwanChip( + label = stringResource(labelRes), + active = isSelected, + tone = if (isSelected) AwanChipTone.Sky else AwanChipTone.Neutral, + onClick = { onAction(InboxAction.SessionFilterToggled(filter)) } + ) + } + } + } + + AwanButton( + onClick = { onAction(InboxAction.FilterDismissed) }, + modifier = Modifier.fillMaxWidth() + ) { + AwanText("Show Results") + } + } } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxViewModel.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxViewModel.kt index 48d1ad92..14528786 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxViewModel.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxViewModel.kt @@ -54,6 +54,10 @@ class InboxViewModel @Inject constructor( current.copy(expandedTaskId = newId) } + InboxAction.FilterClicked -> updateState { it.copy(showFilterSheet = true) } + + InboxAction.FilterDismissed -> updateState { it.copy(showFilterSheet = false) } + InboxAction.RetryClicked -> loadInboxTasks() } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt new file mode 100644 index 00000000..7e57e679 --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt @@ -0,0 +1,480 @@ +package com.awan.feature.goals.impl.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.awan.app.core.designsystem.* +import com.awan.app.core.designsystem.AwanBackButton +import com.awan.app.core.designsystem.AwanCard +import com.awan.app.core.designsystem.AwanText +import com.awan.app.core.designsystem.AwanTheme +import com.awan.app.core.model.Goal +import com.awan.app.core.model.Task +import com.awan.app.core.model.TaskStatus +import com.awan.feature.goals.impl.R +import com.awan.feature.goals.impl.presentation.GoalDetailsAction +import com.awan.feature.goals.impl.presentation.GoalDetailsState +import com.awan.feature.goals.impl.ui.components.goalAccentColor +import com.composables.icons.lucide.Calendar +import com.composables.icons.lucide.Link +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Tag + +@Composable +fun GoalDetailsScreen( + state: GoalDetailsState, + onAction: (GoalDetailsAction) -> Unit, + modifier: Modifier = Modifier +) { + val colors = AwanTheme.colors + + Scaffold( + topBar = { + GoalDetailsTopBar( + title = state.goal?.title ?: "", + onBack = { onAction(GoalDetailsAction.Back) } + ) + }, + containerColor = colors.background, + modifier = modifier + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when { + state.isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = colors.sky + ) + } + state.goal != null -> { + GoalDetailsContent(goal = state.goal) + } + state.error != null -> { + AwanText( + text = state.error, + modifier = Modifier.align(Alignment.Center), + style = AwanTheme.typography.body + ) + } + } + } + } +} + +@Composable +private fun GoalDetailsTopBar( + title: String, + onBack: () -> Unit +) { + val colors = AwanTheme.colors + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + listOf(colors.backgroundStart, colors.background) + ) + ) + .statusBarsPadding() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + contentAlignment = Alignment.Center + ) { + AwanCloudsHorizon( + modifier = Modifier.fillMaxSize() + ) + AwanMascot( + expression = MascotExpression.Curious, + width = 110.dp, + blinkEnabled = true + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AwanBackButton(onClick = onBack) + Spacer(modifier = Modifier.width(12.dp)) + AwanText( + text = title, + style = AwanTheme.typography.title.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = colors.ink + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun GoalDetailsContent( + goal: Goal +) { + val accentColor = goalAccentColor(0) // Default for now + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + GoalHeaderCard(goal = goal) + } + + item { + GoalProgressCard(goal = goal, accentColor = accentColor) + } + + item { + GoalTasksHeader(goal = goal) + } + + itemsIndexed(goal.tasks, key = { _, task -> task.id }) { index, task -> + GoalTaskTimelineItem( + task = task, + index = index + 1, + isLast = index == goal.tasks.lastIndex, + accentColor = accentColor + ) + } + + item { Spacer(modifier = Modifier.height(80.dp)) } + } +} + +@Composable +private fun GoalHeaderCard(goal: Goal) { + val colors = AwanTheme.colors + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(20.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + AwanText( + text = goal.title, + style = AwanTheme.typography.title.copy( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = colors.ink + ), + modifier = Modifier.weight(1f) + ) + + // Status Badge + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(colors.sky.copy(alpha = 0.1f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + AwanText( + text = stringResource(R.string.goals_status_active), + style = AwanTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = colors.sky + ) + ) + } + } + + val description = goal.description + if (!description.isNullOrBlank()) { + AwanText( + text = description, + style = AwanTheme.typography.body.copy( + fontSize = 14.sp, + color = colors.textSecondary + ) + ) + } + + val targetDate = goal.targetDate + if (!targetDate.isNullOrBlank()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Lucide.Calendar, + contentDescription = null, + tint = colors.sky, + modifier = Modifier.size(16.dp) + ) + AwanText( + text = targetDate, + style = AwanTheme.typography.caption.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = colors.textSecondary + ) + ) + } + } + } + } +} + +@Composable +private fun GoalProgressCard(goal: Goal, accentColor: Color) { + val colors = AwanTheme.colors + val lineColor = colors.line + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(20.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AwanText( + text = stringResource(R.string.goals_progress_label), + style = AwanTheme.typography.title.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = colors.textSecondary + ) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AwanText( + text = stringResource(R.string.goals_progress_percentage, (goal.progress * 100).toInt()), + style = AwanTheme.typography.title.copy( + fontSize = 24.sp, + fontWeight = FontWeight.Black, + color = accentColor + ) + ) + AwanText( + text = stringResource(R.string.goals_progress_format, goal.completedTasks, goal.totalTasks), + style = AwanTheme.typography.body.copy( + fontSize = 14.sp, + color = colors.textSecondary + ) + ) + } + + // Progress Bar + Canvas(modifier = Modifier.fillMaxWidth().height(10.dp)) { + val trackH = size.height + val radius = trackH / 2f + val progressWidth = size.width * goal.progress.coerceIn(0f, 1f) + + drawRoundRect( + color = lineColor, + cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), + ) + + if (progressWidth > 0f) { + drawRoundRect( + color = accentColor, + size = androidx.compose.ui.geometry.Size(progressWidth, trackH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), + ) + } + } + } + } +} + +@Composable +private fun GoalTasksHeader(goal: Goal) { + val colors = AwanTheme.colors + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + AwanText( + text = stringResource(R.string.goals_tasks_label), + style = AwanTheme.typography.title.copy( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = colors.ink + ) + ) + val independent = stringResource(R.string.goals_independent_count, 1) // TODO: real logic + val dependent = stringResource(R.string.goals_dependent_count, goal.totalTasks - 1) + AwanText( + text = stringResource(R.string.goals_tasks_summary_format, independent, dependent), + style = AwanTheme.typography.caption.copy( + color = colors.textSecondary + ) + ) + } + + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(colors.sky.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + AwanText( + text = goal.totalTasks.toString(), + style = AwanTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = colors.sky + ) + ) + } + } +} + +@Composable +private fun GoalTaskTimelineItem( + task: Task, + index: Int, + isLast: Boolean, + accentColor: Color +) { + val colors = AwanTheme.colors + val isCompleted = task.status == TaskStatus.COMPLETED + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Timeline Column + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(32.dp) + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(if (isCompleted) accentColor else colors.surface) + .border(1.dp, if (isCompleted) accentColor else colors.line, CircleShape), + contentAlignment = Alignment.Center + ) { + AwanText( + text = index.toString(), + style = AwanTheme.typography.caption.copy( + fontWeight = FontWeight.Bold, + color = if (isCompleted) Color.White else colors.textSecondary + ) + ) + } + + if (!isLast) { + Box( + modifier = Modifier + .width(2.dp) + .height(60.dp) // Adjust based on content + .background(colors.line) + ) + } + } + + // Content Column + Column( + modifier = Modifier.weight(1f).padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = task.title, + style = AwanTheme.typography.body.copy( + fontWeight = FontWeight.Bold, + color = colors.ink, + textDecoration = if (isCompleted) androidx.compose.ui.text.style.TextDecoration.LineThrough else null + ), + modifier = Modifier.weight(1f) + ) + + // Duration Badge + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(colors.line.copy(alpha = 0.5f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + AwanText( + text = stringResource(R.string.goals_task_duration_format, task.estimatedDurationMinutes), + style = AwanTheme.typography.caption.copy(fontSize = 10.sp) + ) + } + } + + val taskDescription = task.description + if (!taskDescription.isNullOrBlank()) { + AwanText( + text = taskDescription, + style = AwanTheme.typography.caption.copy( + color = colors.textSecondary + ), + maxLines = 2 + ) + } + + // Category Chip + task.category?.let { category -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(colors.line.copy(alpha = 0.5f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + AwanText( + text = category.name, + style = AwanTheme.typography.caption.copy(fontSize = 10.sp) + ) + } + } + } + } + } +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsEmptyState.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsEmptyState.kt index 219e59bd..8e460da9 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsEmptyState.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsEmptyState.kt @@ -18,15 +18,9 @@ import com.awan.app.core.designsystem.AwanText import com.awan.app.core.designsystem.AwanTheme import com.awan.app.core.designsystem.MascotExpression import com.awan.feature.goals.impl.R -import com.awan.feature.goals.impl.presentation.GoalsTab -/** - * Empty-state shown when a tab has no goals. - * Active tab → Curious mascot | Completed tab → Celebrate mascot - */ @Composable internal fun GoalsEmptyState( - tab: GoalsTab, modifier: Modifier = Modifier, ) { val colors = AwanTheme.colors @@ -37,17 +31,10 @@ internal fun GoalsEmptyState( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - val expression = when (tab) { - GoalsTab.Active -> MascotExpression.Curious - GoalsTab.Completed -> MascotExpression.Celebrate - } - AwanMascot(expression = expression, width = 100.dp) + AwanMascot(expression = MascotExpression.Curious, width = 100.dp) Spacer(modifier = Modifier.height(20.dp)) AwanText( - text = when (tab) { - GoalsTab.Active -> stringResource(R.string.goals_empty_active_title) - GoalsTab.Completed -> stringResource(R.string.goals_empty_completed_title) - }, + text = stringResource(R.string.goals_empty_active_title), style = AwanTheme.typography.heading.copy( fontSize = 18.sp, color = colors.textPrimary, @@ -56,10 +43,7 @@ internal fun GoalsEmptyState( ) Spacer(modifier = Modifier.height(8.dp)) AwanText( - text = when (tab) { - GoalsTab.Active -> stringResource(R.string.goals_empty_active_subtitle) - GoalsTab.Completed -> stringResource(R.string.goals_empty_completed_subtitle) - }, + text = stringResource(R.string.goals_empty_active_subtitle), style = AwanTheme.typography.body.copy( fontSize = 14.sp, color = colors.textSecondary, diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsScreen.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsScreen.kt index da8e69cc..e7eff9fb 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsScreen.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsScreen.kt @@ -4,19 +4,22 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -27,19 +30,10 @@ import com.awan.app.core.designsystem.AwanTheme import com.awan.feature.goals.impl.R import com.awan.feature.goals.impl.presentation.GoalsAction import com.awan.feature.goals.impl.presentation.GoalsState -import com.awan.feature.goals.impl.presentation.GoalsTab -import com.awan.feature.goals.impl.ui.components.completedGoalColor import com.awan.feature.goals.impl.ui.components.GoalCard -import com.awan.feature.goals.impl.ui.components.GoalsMascotHeader -import com.awan.feature.goals.impl.ui.components.GoalsTabRow +import com.awan.feature.goals.impl.ui.components.GoalsSearchBar import com.awan.feature.goals.impl.ui.components.goalAccentColor -/** - * Root Goals screen. - * - * Assembles the mascot header, tab row, and the goal list (or empty / error state). - * All sub-composables live in [com.awan.feature.goals.impl.ui.components]. - */ @Composable fun GoalsScreen( state: GoalsState, @@ -54,18 +48,50 @@ fun GoalsScreen( Column( modifier = Modifier.fillMaxSize(), ) { - // ── Mascot header ───────────────────────────────────────────────── - GoalsMascotHeader() + // ── Search Bar ────────────────────────────────────────────────── + GoalsSearchBar( + query = state.searchQuery, + onQueryChange = { onAction(GoalsAction.SearchQueryChanged(it)) }, + modifier = Modifier.padding(horizontal = 16.dp), + onFilterClick = null // Only Inbox has filters for now + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // ── Tab row ─────────────────────────────────────────────────────── - GoalsTabRow( - selectedTab = state.tab, - activeCount = state.activeGoals.size, - completedCount = state.completedGoals.size, - onTabSelected = { onAction(GoalsAction.TabSelected(it)) }, - ) + // ── Goals Header ─────────────────────────────────────────────── + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + AwanText( + text = stringResource(R.string.goals_title_count), + style = AwanTheme.typography.title.copy( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = colors.ink + ) + ) + + if (state.goals.isNotEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(colors.sky.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + AwanText( + text = state.goals.size.toString(), + style = AwanTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = colors.sky + ) + ) + } + } + } Spacer(modifier = Modifier.height(16.dp)) @@ -88,34 +114,36 @@ fun GoalsScreen( } else -> { - val goals = when (state.tab) { - GoalsTab.Active -> state.activeGoals - GoalsTab.Completed -> state.completedGoals - } - val isCompletedTab = state.tab == GoalsTab.Completed + val goals = state.filteredGoals if (goals.isEmpty()) { - GoalsEmptyState(tab = state.tab) + GoalsEmptyState() } else { LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { itemsIndexed(goals, key = { _, goal -> goal.id }) { index, goal -> GoalCard( goal = goal, - accentColor = if (isCompletedTab) completedGoalColor() - else goalAccentColor(index), - isCompleted = isCompletedTab, + accentColor = goalAccentColor(index), + onClick = { onAction(GoalsAction.GoalClicked(goal.id)) } ) } - item { Spacer(modifier = Modifier.height(8.dp)) } + item { Spacer(modifier = Modifier.height(16.dp)) } } } } } } + + // ── Floating Clouds ───────────────────────────────────────────── + com.awan.app.core.designsystem.AwanCloudsHorizon( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalCard.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalCard.kt index b2d189c4..1efb6dbc 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalCard.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalCard.kt @@ -1,239 +1,225 @@ package com.awan.feature.goals.impl.ui.components -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.zIndex +import com.awan.app.core.designsystem.AwanCard +import com.awan.app.core.designsystem.AwanCloud import com.awan.app.core.designsystem.AwanText import com.awan.app.core.designsystem.AwanTheme import com.awan.app.core.model.Goal -import com.awan.app.core.model.TaskStatus import com.awan.feature.goals.impl.R +import com.awan.feature.goals.impl.presentation.moscowPriority +import com.composables.icons.lucide.Check +import com.composables.icons.lucide.ChevronRight +import com.composables.icons.lucide.Lucide -/** - * Card for a single goal. - * - * - Tapping the header row toggles the task list open/closed. - * - The chevron arrow rotates 180° when expanded. - * - Progress bar + "X of Y tasks done" are always visible. - * - When expanded: all tasks shown — pending first, completed (strikethrough) last. - */ @Composable internal fun GoalCard( goal: Goal, accentColor: Color, - isCompleted: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { val colors = AwanTheme.colors - val cardShape = RoundedCornerShape(20.dp) - val hasTasks = goal.tasks.isNotEmpty() - var expanded by rememberSaveable(goal.id) { mutableStateOf(false) } - val chevronDeg by animateFloatAsState( - targetValue = if (expanded) 180f else 0f, - animationSpec = tween(durationMillis = 250), - label = "chevron", - ) - Box( - modifier = modifier - .fillMaxWidth() - .shadow(elevation = 2.dp, shape = cardShape, spotColor = Color(0x18000000)) - .clip(cardShape) - .background(colors.surface), + AwanCard( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + contentPadding = PaddingValues(0.dp) // Manual padding for cloud layering ) { - Column(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.fillMaxWidth()) { + // ── Fantasy Cloud Background ────────────────────────────────── + if (goal.progress > 0f) { + AwanCloud( + size = 120.dp, + baseColor = accentColor.copy(alpha = 0.08f), + shadeColor = accentColor.copy(alpha = 0.04f), + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 30.dp, y = (-20).dp) + .alpha(0.6f) + ) + + if (goal.progress > 0.5f) { + AwanCloud( + size = 80.dp, + baseColor = accentColor.copy(alpha = 0.06f), + shadeColor = accentColor.copy(alpha = 0.03f), + modifier = Modifier + .align(Alignment.BottomStart) + .offset(x = (-20).dp, y = 10.dp) + .alpha(0.5f) + ) + } + } - // ── Tappable header row ─────────────────────────────────────────── - Row( - verticalAlignment = Alignment.CenterVertically, + Column( modifier = Modifier - .fillMaxWidth() - .then( - if (hasTasks) Modifier.clickable { expanded = !expanded } - else Modifier, - ) - .padding(horizontal = 16.dp, vertical = 14.dp), + .padding(20.dp) + .zIndex(1f), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // Emoji - AwanText( - text = goal.emoji, - style = AwanTheme.typography.heading.copy(fontSize = 30.sp), - ) - Spacer(modifier = Modifier.width(12.dp)) - - // Title (strikethrough on Completed tab) - AwanText( - text = goal.title, - style = AwanTheme.typography.heading.copy( - fontSize = 17.sp, - fontWeight = FontWeight.ExtraBold, - color = colors.ink, - textDecoration = if (isCompleted) TextDecoration.LineThrough else TextDecoration.None, - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - - // Chevron arrow — only shown when there are tasks to expand - if (hasTasks) { - Spacer(modifier = Modifier.width(8.dp)) - Canvas( + // ── Top Row (Icon, Title, Target) ────────────────────────────── + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Goal Icon + Box( modifier = Modifier - .size(22.dp) - .rotate(chevronDeg), + .size(48.dp) + .clip(CircleShape) + .background(accentColor.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center ) { - val w = size.width - val h = size.height - val strokePx = 2.5f * density - val path = Path().apply { - moveTo(w * 0.2f, h * 0.35f) - lineTo(w * 0.5f, h * 0.65f) - lineTo(w * 0.8f, h * 0.35f) - } - drawPath( - path = path, - color = colors.textSecondary, - style = Stroke( - width = strokePx, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), + AwanText( + text = goal.emoji, + style = AwanTheme.typography.heading.copy(fontSize = 24.sp) ) } + + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = goal.title, + style = AwanTheme.typography.title.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = colors.ink + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + if (goal.isCompleted) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(colors.success), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Lucide.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + } + } else { + Icon( + imageVector = Lucide.ChevronRight, + contentDescription = null, + tint = colors.line, + modifier = Modifier.size(20.dp) + ) + } + } + } } - } - // ── Progress bar + stats (always visible) ──────────────────────── - if (goal.totalTasks > 0) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 14.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - // Canvas-drawn rounded progress bar with dot at progress end + // ── Progress Section ─────────────────────────────────────────── + if (goal.totalTasks > 0) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AwanText( + text = stringResource(R.string.goals_progress_percentage, (goal.progress * 100).toInt()), + style = AwanTheme.typography.title.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Black, + color = accentColor + ) + ) + AwanText( + text = stringResource(R.string.goals_progress_format, goal.completedTasks, goal.totalTasks), + style = AwanTheme.typography.caption.copy( + fontSize = 13.sp, + color = colors.textSecondary + ) + ) + } + + // Progress Bar Canvas( modifier = Modifier - .weight(1f) - .height(8.dp), + .fillMaxWidth() + .height(8.dp) ) { val trackH = size.height val radius = trackH / 2f val progressWidth = size.width * goal.progress.coerceIn(0f, 1f) + // Track drawRoundRect( color = colors.line, cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), ) - // Filled portion + dot + + // Filled portion if (progressWidth > 0f) { drawRoundRect( color = accentColor, size = androidx.compose.ui.geometry.Size(progressWidth, trackH), cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), ) - val dotR = trackH * 0.75f - val dotX = (progressWidth - dotR).coerceAtLeast(dotR) - drawCircle( - color = accentColor, - radius = dotR, - center = Offset(dotX, trackH / 2f), - ) } } - Spacer(modifier = Modifier.width(10.dp)) - AwanText( - text = stringResource(R.string.goals_progress_percentage, (goal.progress * 100).toInt()), - style = AwanTheme.typography.caption.copy( - fontSize = 13.sp, - fontWeight = FontWeight.ExtraBold, - color = accentColor, - ), - ) } - - Spacer(modifier = Modifier.height(4.dp)) - - AwanText( - text = stringResource(R.string.goals_progress_format, goal.completedTasks, goal.totalTasks), - style = AwanTheme.typography.caption.copy( - fontSize = 12.sp, - color = colors.textSecondary, - ), - ) } - } - - // ── Expandable task list ────────────────────────────────────────── - AnimatedVisibility( - visible = hasTasks && expanded, - enter = fadeIn(tween(200)) + expandVertically(tween(250)), - exit = fadeOut(tween(150)) + shrinkVertically(tween(200)), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 14.dp), - ) { - HorizontalDivider( - color = colors.line, - thickness = 1.dp, - ) - Spacer(modifier = Modifier.height(10.dp)) - // Pending tasks first, completed (strikethrough) last - val sortedTasks = goal.tasks.sortedBy { it.status == TaskStatus.COMPLETED } - sortedTasks.forEach { task -> - GoalTaskRow( - title = task.title, - isCompleted = task.status == TaskStatus.COMPLETED, - accentColor = accentColor, + // ── Bottom Chips ────────────────────────────────────────── + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (goal.completedTasks > 0) { + GoalStatusChip( + count = goal.completedTasks, + label = stringResource(R.string.goals_status_completed), + color = colors.success, + icon = Lucide.Check + ) + } + + val activeTasks = goal.totalTasks - goal.completedTasks + if (activeTasks > 0) { + GoalStatusChip( + count = activeTasks, + label = stringResource(R.string.goals_status_active), + color = accentColor, + icon = null ) } } @@ -241,3 +227,64 @@ internal fun GoalCard( } } } + +@Composable +private fun GoalStatusChip( + count: Int, + label: String, + color: Color, + icon: androidx.compose.ui.graphics.vector.ImageVector? = null +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(color.copy(alpha = 0.1f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(14.dp) + ) + } else { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color) + ) + } + + AwanText( + text = "$count $label", + style = AwanTheme.typography.caption.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = color + ) + ) + } +} + +@Composable +private fun TargetDateBadge(date: String) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(AwanTheme.colors.sky.copy(alpha = 0.1f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + AwanText( + text = stringResource(R.string.goals_target_date, date), + style = AwanTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = AwanTheme.colors.sky + ) + ) + } +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalColors.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalColors.kt index a94b60f6..8ac67f37 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalColors.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalColors.kt @@ -22,7 +22,3 @@ internal fun goalAccentColor(index: Int): Color { val palette = goalAccentColors() return palette[index % palette.size] } - -/** Gold color used for every card on the Completed tab. */ -@Composable -internal fun completedGoalColor(): Color = AwanTheme.colors.zoneSun diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalTaskRow.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalTaskRow.kt deleted file mode 100644 index 2e8eadbb..00000000 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalTaskRow.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.awan.feature.goals.impl.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -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.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.awan.app.core.designsystem.AwanText -import com.awan.app.core.designsystem.AwanTheme - -/** - * A single task row inside an expanded goal card. - * Shows a colored dot bullet followed by the task title. - * Completed tasks are rendered with a strikethrough. - */ -@Composable -internal fun GoalTaskRow( - title: String, - isCompleted: Boolean, - accentColor: Color, - modifier: Modifier = Modifier, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .fillMaxWidth() - .padding(vertical = 3.dp), - ) { - // Colored dot bullet - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(accentColor), - ) - Spacer(modifier = Modifier.width(10.dp)) - AwanText( - text = title, - style = AwanTheme.typography.body.copy( - fontSize = 13.5.sp, - color = AwanTheme.colors.textSecondary, - textDecoration = if (isCompleted) TextDecoration.LineThrough else TextDecoration.None, - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } -} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsMascotHeader.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsMascotHeader.kt deleted file mode 100644 index 43fea741..00000000 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsMascotHeader.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.awan.feature.goals.impl.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.awan.app.core.designsystem.AwanMascot -import com.awan.app.core.designsystem.MascotExpression - -/** - * Header area shown at the top of the Goals screen: - * Awan mascot centred with two decorative cloud shapes on each side. - */ -@Composable -internal fun GoalsMascotHeader(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxWidth() - .height(120.dp), - contentAlignment = Alignment.Center, - ) { - // Left decorative cloud - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp) - .size(width = 72.dp, height = 44.dp) - .clip(RoundedCornerShape(22.dp)) - .background(Color.White.copy(alpha = 0.65f)), - ) - // Right decorative cloud - Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 16.dp) - .size(width = 72.dp, height = 44.dp) - .clip(RoundedCornerShape(22.dp)) - .background(Color.White.copy(alpha = 0.65f)), - ) - // Main mascot (gently floating animation built into AwanMascot) - AwanMascot( - expression = MascotExpression.Greet, - width = 100.dp, - ) - } -} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt new file mode 100644 index 00000000..9b2599cc --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt @@ -0,0 +1,114 @@ +package com.awan.feature.goals.impl.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.awan.app.core.designsystem.AwanText +import com.awan.app.core.designsystem.AwanTheme +import com.awan.feature.goals.impl.R +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Search +import com.composables.icons.lucide.SlidersHorizontal + +@Composable +internal fun GoalsSearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + onFilterClick: (() -> Unit)? = null, +) { + val colors = AwanTheme.colors + val shape = RoundedCornerShape(16.dp) + + Row( + modifier = modifier + .fillMaxWidth() + .height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(shape) + .background(colors.surface) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Lucide.Search, + contentDescription = null, + tint = colors.textSecondary, + modifier = Modifier.size(20.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Box(modifier = Modifier.weight(1f)) { + if (query.isEmpty()) { + AwanText( + text = stringResource(R.string.goals_search_placeholder), + style = AwanTheme.typography.body.copy( + color = colors.meta, + fontSize = 15.sp + ) + ) + } + + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + textStyle = AwanTheme.typography.body.copy( + color = colors.ink, + fontSize = 15.sp + ), + cursorBrush = SolidColor(colors.sky), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search) + ) + } + } + + if (onFilterClick != null) { + Box( + modifier = Modifier + .size(52.dp) + .clip(shape) + .background(colors.surface) + .clickable(onClick = onFilterClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Lucide.SlidersHorizontal, + contentDescription = null, + tint = colors.textSecondary, + modifier = Modifier.size(20.dp) + ) + } + } + } +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsTabRow.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsTabRow.kt deleted file mode 100644 index 3ec30989..00000000 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsTabRow.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.awan.feature.goals.impl.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource -import com.awan.app.core.designsystem.AwanText -import com.awan.app.core.designsystem.AwanTheme -import com.awan.feature.goals.impl.presentation.GoalsTab -import com.awan.feature.goals.impl.R - -/** - * Pill-shaped tab row that switches between Active and Completed goals. - * Shows the count of goals in each tab next to the label (e.g. "Active 2"). - */ -@Composable -internal fun GoalsTabRow( - selectedTab: GoalsTab, - activeCount: Int, - completedCount: Int, - onTabSelected: (GoalsTab) -> Unit, - modifier: Modifier = Modifier, -) { - val shape = RoundedCornerShape(99.dp) - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .clip(shape) - .background(AwanTheme.colors.line) - .padding(4.dp), - ) { - GoalsTab.entries.forEach { tab -> - val isSelected = tab == selectedTab - val count = when (tab) { - GoalsTab.Active -> activeCount - GoalsTab.Completed -> completedCount - } - val label = when (tab) { - GoalsTab.Active -> stringResource(R.string.goals_tab_active) - GoalsTab.Completed -> stringResource(R.string.goals_tab_completed) - } - val tabShape = RoundedCornerShape(99.dp) - - Box( - modifier = Modifier - .weight(1f) - .height(44.dp) - .then( - if (isSelected) { - Modifier - .shadow(elevation = 2.dp, shape = tabShape, spotColor = Color(0x22000000)) - .clip(tabShape) - .background(AwanTheme.colors.surface) - } else { - Modifier.clip(tabShape) - }, - ) - .selectable( - selected = isSelected, - onClick = { onTabSelected(tab) }, - role = Role.Tab, - ), - contentAlignment = Alignment.Center, - ) { - AwanText( - text = stringResource(R.string.goals_tab_badge_format, label, count), - style = AwanTheme.typography.button.copy( - color = if (isSelected) AwanTheme.colors.ink else AwanTheme.colors.textSecondary, - fontWeight = if (isSelected) FontWeight.ExtraBold else FontWeight.SemiBold, - fontSize = 15.sp, - ), - ) - } - } - } -} diff --git a/feature/goals/impl/src/main/res/values-ar/strings.xml b/feature/goals/impl/src/main/res/values-ar/strings.xml index aebb02d6..e9f2c58a 100644 --- a/feature/goals/impl/src/main/res/values-ar/strings.xml +++ b/feature/goals/impl/src/main/res/values-ar/strings.xml @@ -1,9 +1,13 @@ + البحث عن المهام أو الجلسات + الأهداف + نشط + مكتمل + %1$d مهام + %1$d مستقل + %1$d تابع أهدافي - النشطة - المكتملة - %1$s %2$d %1$d من %2$d مهمة منجزة %1$d%% لا توجد أهداف نشطة بعد @@ -15,6 +19,12 @@ إعادة المحاولة موسع مطوي + الهدف %1$s + التقدم + المهام + %1$s · %2$s + %1$d دقيقة + يعتمد على مجدول مكتمل ملغى diff --git a/feature/goals/impl/src/main/res/values/strings.xml b/feature/goals/impl/src/main/res/values/strings.xml index d891ecab..471caed5 100644 --- a/feature/goals/impl/src/main/res/values/strings.xml +++ b/feature/goals/impl/src/main/res/values/strings.xml @@ -1,9 +1,12 @@ - My Goals - Active - Done - %1$s %2$d + Search tasks or sessions + Goals + Active + Completed + %1$d tasks + %1$d independent + %1$d dependent %1$d of %2$d tasks done %1$d%% No active goals yet @@ -15,6 +18,12 @@ Retry Expanded Collapsed + Target %1$s + Progress + Tasks + %1$s · %2$s + %1$d min + Depends on Scheduled Completed Cancelled From 37ea359afd2135bdfb01ae6e0f5092f4f453d0ef Mon Sep 17 00:00:00 2001 From: esraaehab333 Date: Tue, 11 Aug 2026 13:56:40 +0300 Subject: [PATCH 2/5] fix: remove goal priority business logic from UI and clean up goal details/repository implementation --- app/src/main/java/com/awan/app/AwanApp.kt | 3 +- .../app/core/data/goal/GoalRepositoryImpl.kt | 99 +++++++------------ .../goal/GoalDecompositionRepositoryTest.kt | 28 +++++- .../app/core/data/goal/GoalRepositoryTest.kt | 29 +++++- .../home/local/HomeLocalDataSourceTest.kt | 9 +- .../data/sync/OfflineSyncCoordinatorTest.kt | 6 +- .../data/task/AiTaskRepositoryImplTest.kt | 4 +- .../core/data/task/TaskRepositoryImplTest.kt | 6 +- .../impl/navigation/GoalsEntryProvider.kt | 11 +-- .../goals/impl/presentation/GoalDetailsMvi.kt | 2 + .../impl/presentation/GoalDetailsViewModel.kt | 49 ++++++++- .../goals/impl/presentation/GoalsAction.kt | 8 ++ .../goals/impl/presentation/GoalsEvent.kt | 5 + .../goals/impl/presentation/GoalsMvi.kt | 47 --------- .../goals/impl/presentation/GoalsState.kt | 33 +++++++ .../goals/impl/presentation/GoalsViewModel.kt | 2 + .../goals/impl/presentation/InboxAction.kt | 11 +++ .../{InboxMvi.kt => InboxModels.kt} | 29 ------ .../goals/impl/presentation/InboxState.kt | 18 ++++ .../goals/impl/ui/GoalDetailsScreen.kt | 79 ++++++++++++--- .../goals/impl/ui/components/GoalCard.kt | 1 - .../impl/src/main/res/values-ar/strings.xml | 1 - 22 files changed, 292 insertions(+), 188 deletions(-) create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsAction.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsEvent.kt delete mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsMvi.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsState.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxAction.kt rename feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/{InboxMvi.kt => InboxModels.kt} (66%) create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxState.kt diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt index fab1c6a1..f0bdf9b7 100644 --- a/app/src/main/java/com/awan/app/AwanApp.kt +++ b/app/src/main/java/com/awan/app/AwanApp.kt @@ -245,7 +245,8 @@ fun AwanApp( chatEntry() goalsEntry( onNavigateToGoalDetails = { id -> navigator.navigate(com.awan.feature.goals.api.GoalDetailsRoute(id)) }, - onBack = { navigator.goBack() } + onBack = { navigator.goBack() }, + onOpenAddTask = { _ -> showAddTask = true } ) aiTasksEntry(onBack = { navigator.goBack() }) inventoryEntry(onBack = { navigator.goBack() }) diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt index 1f78eae4..95b5c8b7 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt @@ -19,9 +19,12 @@ import com.awan.app.core.network.dto.GoalDecomposeRequest import com.awan.app.core.network.dto.goal.ConfirmAiScheduleRequest import com.awan.app.core.network.dto.goal.CreateGoalRequest import com.awan.app.core.network.dto.goal.ProposedGoalSessionDto +import com.awan.app.core.network.dto.GoalInfoResponse import com.awan.app.core.data.sync.SyncTtl +import com.awan.app.core.database.model.GoalEntity import com.awan.app.core.data.task.toEntity import com.awan.app.core.data.task.toTaskModel +import com.awan.app.core.common.result.suspendOnSuccess import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import javax.inject.Inject @@ -44,24 +47,9 @@ class GoalRepositoryImpl @Inject constructor( override suspend fun getGoals(): Result> = withContext(ioDispatcher) { if (connectivityMonitor.isCurrentlyOnline()) { - val result = remoteDataSource.getGoals() - if (result is Result.Success) { - val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) - goalDao.upsertGoals(result.data.map { it.toEntity().copy(expiryTime = expiry) }) - - // Also cache tasks if provided - result.data.forEach { goalDto -> - if (goalDto.tasks.isNotEmpty()) { - taskDao.upsertTasks(goalDto.tasks.map { it.toEntity(expiryTime = expiry) }) - } - } - } - } - val entities = goalDao.getAllGoals() - val models = entities.map { entity -> - val tasks = taskDao.getTasksByGoal(entity.id).map { it.toTaskModel() } - entity.toModel().copy(tasks = tasks) + remoteDataSource.getGoals().suspendOnSuccess { syncGoals(it) } } + val models = goalDao.getAllGoals().map { it.toModelWithTasks() } Result.Success(models) } @@ -74,58 +62,29 @@ class GoalRepositoryImpl @Inject constructor( return@withContext Result.Error(AppError.Network) } remoteDataSource.createGoal( - CreateGoalRequest( - title = title, - description = description, - targetDate = targetDate, - ), + CreateGoalRequest(title = title, description = description, targetDate = targetDate), ).map { dto -> - val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) - val entity = dto.toEntity().copy(expiryTime = expiry) - goalDao.upsertGoal(entity) - if (dto.tasks.isNotEmpty()) { - taskDao.upsertTasks(dto.tasks.map { it.toEntity(expiryTime = expiry) }) - } - entity.toModel().copy(tasks = dto.tasks.map { it.toTaskModel() }) + syncGoal(dto) + dto.toEntity().toModelWithTasks() } } override suspend fun getInboxGoal(): Result = withContext(ioDispatcher) { if (connectivityMonitor.isCurrentlyOnline()) { - val result = remoteDataSource.getInboxGoal() - if (result is Result.Success) { - val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) - goalDao.upsertGoal(result.data.toEntity().copy(expiryTime = expiry)) - if (result.data.tasks.isNotEmpty()) { - taskDao.upsertTasks(result.data.tasks.map { it.toEntity(expiryTime = expiry) }) - } - } + remoteDataSource.getInboxGoal().suspendOnSuccess { syncGoal(it) } } - val cached = goalDao.getAllGoals().find { it.isInbox } - if (cached != null) { - val tasks = taskDao.getTasksByGoal(cached.id).map { it.toTaskModel() } - return@withContext Result.Success(cached.toModel().copy(tasks = tasks)) - } - Result.Error(AppError.NotFound) + goalDao.getAllGoals().find { it.isInbox }?.let { + Result.Success(it.toModelWithTasks()) + } ?: Result.Error(AppError.NotFound) } override suspend fun getGoal(goalId: String): Result = withContext(ioDispatcher) { if (connectivityMonitor.isCurrentlyOnline()) { - val result = remoteDataSource.getGoal(goalId) - if (result is Result.Success) { - val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) - goalDao.upsertGoal(result.data.toEntity().copy(expiryTime = expiry)) - if (result.data.tasks.isNotEmpty()) { - taskDao.upsertTasks(result.data.tasks.map { it.toEntity(expiryTime = expiry) }) - } - } + remoteDataSource.getGoal(goalId).suspendOnSuccess { syncGoal(it) } } - val entity = goalDao.getGoal(goalId) - if (entity != null) { - val tasks = taskDao.getTasksByGoal(goalId).map { it.toTaskModel() } - return@withContext Result.Success(entity.toModel().copy(tasks = tasks)) - } - Result.Error(AppError.NotFound) + goalDao.getGoal(goalId)?.let { + Result.Success(it.toModelWithTasks()) + } ?: Result.Error(AppError.NotFound) } override suspend fun deleteGoal(goalId: String): Result = withContext(ioDispatcher) { @@ -162,13 +121,31 @@ class GoalRepositoryImpl @Inject constructor( return@withContext Result.Error(AppError.Network) } remoteDataSource.confirmDecomposition(sessionId).map { dto -> - val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) - val entity = dto.toEntity().copy(expiryTime = expiry) - goalDao.upsertGoal(entity) + syncGoal(dto) + dto.toEntity().toModelWithTasks() + } + } + + private suspend fun GoalEntity.toModelWithTasks(): Goal { + val tasks = taskDao.getTasksByGoal(id).map { it.toTaskModel() } + return toModel().copy(tasks = tasks) + } + + private suspend fun syncGoal(dto: GoalInfoResponse) { + val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) + goalDao.upsertGoal(dto.toEntity().copy(expiryTime = expiry)) + if (dto.tasks.isNotEmpty()) { + taskDao.upsertTasks(dto.tasks.map { it.toEntity(expiryTime = expiry) }) + } + } + + private suspend fun syncGoals(dtos: List) { + val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) + goalDao.upsertGoals(dtos.map { it.toEntity().copy(expiryTime = expiry) }) + dtos.forEach { dto -> if (dto.tasks.isNotEmpty()) { taskDao.upsertTasks(dto.tasks.map { it.toEntity(expiryTime = expiry) }) } - entity.toModel().copy(tasks = dto.tasks.map { it.toTaskModel() }) } } diff --git a/core/data/src/test/java/com/awan/app/core/data/goal/GoalDecompositionRepositoryTest.kt b/core/data/src/test/java/com/awan/app/core/data/goal/GoalDecompositionRepositoryTest.kt index 7d8e868a..d3648299 100644 --- a/core/data/src/test/java/com/awan/app/core/data/goal/GoalDecompositionRepositoryTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/goal/GoalDecompositionRepositoryTest.kt @@ -27,7 +27,10 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import com.awan.app.core.database.dao.GoalDao +import com.awan.app.core.database.dao.TaskDao import com.awan.app.core.database.model.GoalEntity +import com.awan.app.core.database.model.TaskDependencyEntity +import com.awan.app.core.database.model.TaskEntity /** * Tests for the remote/repository layer: @@ -93,12 +96,29 @@ class GoalDecompositionRepositoryTest { override fun observeGoalsByStatus(status: String): Flow> = flowOf(emptyList()) override fun observeGoal(goalId: String): Flow = MutableStateFlow(null) override suspend fun getGoal(goalId: String): GoalEntity? = null - override fun observeInboxGoal(): Flow = MutableStateFlow(null) override suspend fun deleteGoal(goalId: String) {} - override suspend fun getActiveNonInboxGoalIds(): List = emptyList() override suspend fun getMinExpiryTime(): Long? = null } + private val noOpTaskDao = object : TaskDao { + override suspend fun upsertTask(task: TaskEntity) {} + override suspend fun upsertTasks(tasks: List) {} + override fun observeTasksByGoal(goalId: String): Flow> = flowOf(emptyList()) + override suspend fun getTasksByGoal(goalId: String): List = emptyList() + override fun observeTask(taskId: String): Flow = flowOf(null) + override suspend fun getTask(taskId: String): TaskEntity? = null + override suspend fun deleteTask(taskId: String) {} + override suspend fun upsertDependency(dependency: TaskDependencyEntity) {} + override suspend fun upsertDependencies(dependencies: List) {} + override suspend fun deleteDependency(dependency: TaskDependencyEntity) {} + override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) + override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) + override suspend fun deleteAllDependenciesForTask(taskId: String) {} + override suspend fun replaceTasksForGoal(goalId: String, tasks: List, dependencies: List) {} + override suspend fun deleteTasksByGoal(goalId: String) {} + override suspend fun nullifyOrphanedGoalReferences() {} + } + // --- C. Remote data source / repository behavior --- @Test @@ -174,7 +194,7 @@ class GoalDecompositionRepositoryTest { override suspend fun continueDecomposition(request: GoalDecomposeRequest): Result = expectedError } - val repository = GoalRepositoryImpl(fakeDs, noOpGoalDao, onlineMonitor) + val repository = GoalRepositoryImpl(fakeDs, noOpGoalDao, noOpTaskDao, onlineMonitor, testDispatcher) val result = repository.continueDecomposition(sessionId = null, message = "Test") assertEquals(expectedError, result) @@ -188,7 +208,7 @@ class GoalDecompositionRepositoryTest { override suspend fun confirmDecomposition(sessionId: String): Result = expectedError } - val repository = GoalRepositoryImpl(fakeDs, noOpGoalDao, onlineMonitor) + val repository = GoalRepositoryImpl(fakeDs, noOpGoalDao, noOpTaskDao, onlineMonitor, testDispatcher) val result = repository.confirmDecomposition(sessionId = "sess-x") diff --git a/core/data/src/test/java/com/awan/app/core/data/goal/GoalRepositoryTest.kt b/core/data/src/test/java/com/awan/app/core/data/goal/GoalRepositoryTest.kt index 2d833788..2de3ff7d 100644 --- a/core/data/src/test/java/com/awan/app/core/data/goal/GoalRepositoryTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/goal/GoalRepositoryTest.kt @@ -5,7 +5,10 @@ import com.awan.app.core.common.result.Result import com.awan.app.core.data.goal.remote.GoalRemoteDataSource import com.awan.app.core.data.goal.remote.GoalRemoteDataSourceImpl import com.awan.app.core.database.dao.GoalDao +import com.awan.app.core.database.dao.TaskDao import com.awan.app.core.database.model.GoalEntity +import com.awan.app.core.database.model.TaskDependencyEntity +import com.awan.app.core.database.model.TaskEntity import com.awan.app.core.network.api.GoalApiService import com.awan.app.core.network.dto.GoalInfoResponse import com.awan.app.core.network.dto.GoalStatusDto @@ -84,12 +87,29 @@ class GoalRepositoryTest { override fun observeGoalsByStatus(status: String): Flow> = flowOf(emptyList()) override fun observeGoal(goalId: String): Flow = MutableStateFlow(null) override suspend fun getGoal(goalId: String): GoalEntity? = stored.firstOrNull { it.id == goalId } - override fun observeInboxGoal(): Flow = MutableStateFlow(null) override suspend fun deleteGoal(goalId: String) {} - override suspend fun getActiveNonInboxGoalIds(): List = emptyList() override suspend fun getMinExpiryTime(): Long? = null } + private class FakeTaskDao : TaskDao { + override suspend fun upsertTask(task: TaskEntity) {} + override suspend fun upsertTasks(tasks: List) {} + override fun observeTasksByGoal(goalId: String): Flow> = flowOf(emptyList()) + override suspend fun getTasksByGoal(goalId: String): List = emptyList() + override fun observeTask(taskId: String): Flow = flowOf(null) + override suspend fun getTask(taskId: String): TaskEntity? = null + override suspend fun deleteTask(taskId: String) {} + override suspend fun upsertDependency(dependency: TaskDependencyEntity) {} + override suspend fun upsertDependencies(dependencies: List) {} + override suspend fun deleteDependency(dependency: TaskDependencyEntity) {} + override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) + override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) + override suspend fun deleteAllDependenciesForTask(taskId: String) {} + override suspend fun replaceTasksForGoal(goalId: String, tasks: List, dependencies: List) {} + override suspend fun deleteTasksByGoal(goalId: String) {} + override suspend fun nullifyOrphanedGoalReferences() {} + } + @Test fun `remote data source returns page content with expected default arguments`() = runTest(testDispatcher) { var capturedStatus: String? = "NON_NULL" @@ -146,10 +166,11 @@ class GoalRepositoryTest { override fun isCurrentlyOnline(): Boolean = true } val repository = GoalRepositoryImpl( - remoteDataSource = FakeGoalRemoteDataSource(), goalDao = dao, + taskDao = FakeTaskDao(), connectivityMonitor = onlineMonitor, + ioDispatcher = testDispatcher, ) val result = repository.getGoals() @@ -171,7 +192,9 @@ class GoalRepositoryTest { val repository = GoalRepositoryImpl( remoteDataSource = FakeGoalRemoteDataSource(), goalDao = FakeGoalDao(stored = emptyList()), + taskDao = FakeTaskDao(), connectivityMonitor = onlineMonitor, + ioDispatcher = testDispatcher, ) diff --git a/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt b/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt index cd49b4b0..26084731 100644 --- a/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt @@ -140,9 +140,7 @@ private class FakeTaskDao : TaskDao { override suspend fun deleteAllDependenciesForTask(taskId: String) { clearedDependencies += taskId } override suspend fun upsertTasks(tasks: List) {} override fun observeTasksByGoal(goalId: String): Flow> = flowOf(emptyList()) - override fun observeInboxTasks(): Flow> = flowOf(emptyList()) - override fun observeAllTasks(): Flow> = flowOf(emptyList()) - override suspend fun getAllTasks(): List = emptyList() + override suspend fun getTasksByGoal(goalId: String): List = emptyList() override fun observeTask(taskId: String): Flow = flowOf(null) override suspend fun upsertDependency(dependency: TaskDependencyEntity) {} override suspend fun upsertDependencies(dependencies: List) {} @@ -151,6 +149,11 @@ private class FakeTaskDao : TaskDao { override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) override suspend fun deleteTasksByGoal(goalId: String) {} override suspend fun nullifyOrphanedGoalReferences() {} + override suspend fun replaceTasksForGoal( + goalId: String, + tasks: List, + dependencies: List + ) {} } private class FakeUserDao : UserDao { diff --git a/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt b/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt index 22ad5a18..91eaa511 100644 --- a/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt @@ -185,9 +185,7 @@ private class FakeTaskDao : TaskDao { override suspend fun upsertTask(task: TaskEntity) { upsertedTasks += task } override suspend fun upsertTasks(tasks: List) { upsertedTasks += tasks } override fun observeTasksByGoal(goalId: String): Flow> = flowOf(emptyList()) - override fun observeInboxTasks(): Flow> = flowOf(emptyList()) - override fun observeAllTasks(): Flow> = flowOf(emptyList()) - override suspend fun getAllTasks(): List = emptyList() + override suspend fun getTasksByGoal(goalId: String): List = emptyList() override fun observeTask(taskId: String): Flow = MutableStateFlow(null) override suspend fun getTask(taskId: String): TaskEntity? = null override suspend fun deleteTask(taskId: String) {} @@ -237,9 +235,7 @@ private class FakeGoalDao : GoalDao { override fun observeGoalsByStatus(status: String): Flow> = flowOf(emptyList()) override fun observeGoal(goalId: String): Flow = MutableStateFlow(null) override suspend fun getGoal(goalId: String): GoalEntity? = null - override fun observeInboxGoal(): Flow = MutableStateFlow(null) override suspend fun deleteGoal(goalId: String) {} - override suspend fun getActiveNonInboxGoalIds(): List = emptyList() override suspend fun getMinExpiryTime(): Long? = null } diff --git a/core/data/src/test/java/com/awan/app/core/data/task/AiTaskRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/task/AiTaskRepositoryImplTest.kt index 569951c4..857ace32 100644 --- a/core/data/src/test/java/com/awan/app/core/data/task/AiTaskRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/task/AiTaskRepositoryImplTest.kt @@ -94,9 +94,7 @@ class AiTaskRepositoryImplTest { override suspend fun upsertTask(task: TaskEntity) {} override suspend fun upsertTasks(tasks: List) {} override fun observeTasksByGoal(goalId: String): Flow> = flowOf(emptyList()) - override fun observeInboxTasks(): Flow> = flowOf(emptyList()) - override fun observeAllTasks(): Flow> = flowOf(emptyList()) - override suspend fun getAllTasks(): List = emptyList() + override suspend fun getTasksByGoal(goalId: String): List = emptyList() override fun observeTask(taskId: String): Flow = flowOf(null) override suspend fun getTask(taskId: String): TaskEntity? = null override suspend fun deleteTask(taskId: String) {} diff --git a/core/data/src/test/java/com/awan/app/core/data/task/TaskRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/task/TaskRepositoryImplTest.kt index 9ac96092..347ba5fa 100644 --- a/core/data/src/test/java/com/awan/app/core/data/task/TaskRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/task/TaskRepositoryImplTest.kt @@ -49,9 +49,7 @@ private class FakeTaskDao : TaskDao { override suspend fun upsertTask(task: TaskEntity) { upsertedTasks += task } override suspend fun upsertTasks(tasks: List) { upsertedTasks += tasks } override fun observeTasksByGoal(goalId: String): Flow> = flowOf(emptyList()) - override fun observeInboxTasks(): Flow> = flowOf(emptyList()) - override fun observeAllTasks(): Flow> = flowOf(emptyList()) - override suspend fun getAllTasks(): List = emptyList() + override suspend fun getTasksByGoal(goalId: String): List = emptyList() override fun observeTask(taskId: String): Flow = MutableStateFlow(null) override suspend fun getTask(taskId: String): TaskEntity? = null override suspend fun deleteTask(taskId: String) { deletedTaskIds += taskId } @@ -249,9 +247,7 @@ private class FakeGoalDao : com.awan.app.core.database.dao.GoalDao { override fun observeGoalsByStatus(status: String): Flow> = flowOf(emptyList()) override fun observeGoal(goalId: String): Flow = MutableStateFlow(null) override suspend fun getGoal(goalId: String): com.awan.app.core.database.model.GoalEntity? = null - override fun observeInboxGoal(): Flow = MutableStateFlow(null) override suspend fun deleteGoal(goalId: String) {} - override suspend fun getActiveNonInboxGoalIds(): List = emptyList() override suspend fun getMinExpiryTime(): Long? = null } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt index 6193fd4a..50712130 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt @@ -61,6 +61,7 @@ import com.composables.icons.lucide.Target fun EntryProviderScope.goalsEntry( onNavigateToGoalDetails: (String) -> Unit = {}, onBack: () -> Unit = {}, + onOpenAddTask: (String) -> Unit = {}, ) { entry { GoalsRouteScreen( @@ -78,12 +79,10 @@ fun EntryProviderScope.goalsEntry( GoalDetailsScreen( state = state, - onAction = { action -> - when (action) { - GoalDetailsAction.Back -> onBack() - else -> viewModel.onAction(action) - } - } + events = viewModel.events, + onAction = viewModel::onAction, + onNavigateBack = onBack, + onOpenAddTask = onOpenAddTask ) } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt index ba1456a7..338a973e 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt @@ -11,4 +11,6 @@ data class GoalDetailsState( sealed interface GoalDetailsAction { data object Retry : GoalDetailsAction data object Back : GoalDetailsAction + data object DeleteClicked : GoalDetailsAction + data object AddTaskClicked : GoalDetailsAction } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt index 9768a938..b81077fc 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt @@ -3,31 +3,42 @@ package com.awan.feature.goals.impl.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.goal.usecase.DeleteGoalUseCase import com.awan.app.core.domain.goal.usecase.GetGoalUseCase import com.awan.app.core.model.Goal import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +sealed interface GoalDetailsEvent { + data object NavigateBack : GoalDetailsEvent + data class OpenAddTask(val goalId: String) : GoalDetailsEvent +} + @HiltViewModel class GoalDetailsViewModel @Inject constructor( private val getGoalUseCase: GetGoalUseCase, + private val deleteGoalUseCase: DeleteGoalUseCase, ) : ViewModel() { private val _state = MutableStateFlow(GoalDetailsState()) val state: StateFlow = _state.asStateFlow() + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + fun loadGoal(id: String) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } when (val result = getGoalUseCase(id)) { - is Result.Success<*> -> { - val goal = result.data as? Goal - _state.update { it.copy(isLoading = false, goal = goal) } + is Result.Success -> { + _state.update { it.copy(isLoading = false, goal = result.data) } } is Result.Error -> { _state.update { it.copy(isLoading = false, error = "Failed to load goal") } @@ -43,7 +54,37 @@ class GoalDetailsViewModel @Inject constructor( _state.value.goal?.id?.let { loadGoal(it) } } - GoalDetailsAction.Back -> {} + GoalDetailsAction.Back -> { + viewModelScope.launch { + _events.send(GoalDetailsEvent.NavigateBack) + } + } + + GoalDetailsAction.DeleteClicked -> deleteGoal() + + GoalDetailsAction.AddTaskClicked -> { + _state.value.goal?.id?.let { + viewModelScope.launch { + _events.send(GoalDetailsEvent.OpenAddTask(it)) + } + } + } + } + } + + private fun deleteGoal() { + val goalId = _state.value.goal?.id ?: return + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + when (deleteGoalUseCase(goalId)) { + is Result.Success -> { + _events.send(GoalDetailsEvent.NavigateBack) + } + is Result.Error -> { + _state.update { it.copy(isLoading = false, error = "Failed to delete goal") } + } + Result.Loading -> {} + } } } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsAction.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsAction.kt new file mode 100644 index 00000000..c5b9897b --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsAction.kt @@ -0,0 +1,8 @@ +package com.awan.feature.goals.impl.presentation + +sealed interface GoalsAction { + data class SearchQueryChanged(val query: String) : GoalsAction + data object RetryClicked : GoalsAction + data class GoalClicked(val goalId: String) : GoalsAction + data class TabSelected(val tab: GoalsTab) : GoalsAction +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsEvent.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsEvent.kt new file mode 100644 index 00000000..af948e7c --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsEvent.kt @@ -0,0 +1,5 @@ +package com.awan.feature.goals.impl.presentation + +sealed interface GoalsEvent { + data class NavigateToGoalDetails(val goalId: String) : GoalsEvent +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsMvi.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsMvi.kt deleted file mode 100644 index 4b063ae6..00000000 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsMvi.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.awan.feature.goals.impl.presentation - -import com.awan.app.core.model.Goal -import com.awan.app.core.model.GoalStatus - -data class GoalsState( - val isLoading: Boolean = true, - val isError: Boolean = false, - val goals: List = emptyList(), - val searchQuery: String = "", -) { - val activeGoals: List = goals.filter { it.status == GoalStatus.ACTIVE } - - val filteredGoals: List - get() { - val baseList = if (searchQuery.isBlank()) { - activeGoals - } else { - activeGoals.filter { it.title.contains(searchQuery, ignoreCase = true) } - } - // Sort by priority (Must > Should > Could > Wont) - return baseList.sortedBy { it.moscowPriority.ordinal } - } -} - -enum class MoscowPriority { - Must, Should, Could, Wont -} - -val Goal.moscowPriority: MoscowPriority - get() = when { - title.contains("MUST", ignoreCase = true) || title.contains("مهم", ignoreCase = true) -> MoscowPriority.Must - title.contains("SHOULD", ignoreCase = true) -> MoscowPriority.Should - title.contains("COULD", ignoreCase = true) -> MoscowPriority.Could - title.contains("WONT", ignoreCase = true) -> MoscowPriority.Wont - else -> MoscowPriority.Could // Default fantasy drift - } - -sealed interface GoalsAction { - data class SearchQueryChanged(val query: String) : GoalsAction - data object RetryClicked : GoalsAction - data class GoalClicked(val goalId: String) : GoalsAction -} - -sealed interface GoalsEvent { - data class NavigateToGoalDetails(val goalId: String) : GoalsEvent -} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsState.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsState.kt new file mode 100644 index 00000000..80af4418 --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsState.kt @@ -0,0 +1,33 @@ +package com.awan.feature.goals.impl.presentation + +import com.awan.app.core.model.Goal +import com.awan.app.core.model.GoalStatus + +enum class GoalsTab { + Active, Completed +} + +data class GoalsState( + val isLoading: Boolean = true, + val isError: Boolean = false, + val goals: List = emptyList(), + val searchQuery: String = "", + val tab: GoalsTab = GoalsTab.Active, +) { + val activeGoals: List = goals.filter { it.status == GoalStatus.ACTIVE } + val completedGoals: List = goals.filter { it.status == GoalStatus.ACHIEVED } + + val filteredGoals: List + get() { + val baseList = when (tab) { + GoalsTab.Active -> activeGoals + GoalsTab.Completed -> completedGoals + } + + return if (searchQuery.isBlank()) { + baseList + } else { + baseList.filter { it.title.contains(searchQuery, ignoreCase = true) } + } + } +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt index 3391103b..48cea179 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt @@ -38,6 +38,8 @@ class GoalsViewModel @Inject constructor( _events.send(GoalsEvent.NavigateToGoalDetails(action.goalId)) } } + + is GoalsAction.TabSelected -> _state.update { it.copy(tab = action.tab) } } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxAction.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxAction.kt new file mode 100644 index 00000000..9f31650b --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxAction.kt @@ -0,0 +1,11 @@ +package com.awan.feature.goals.impl.presentation + +sealed interface InboxAction { + data class SearchQueryChanged(val query: String) : InboxAction + data class StatusFilterToggled(val filter: InboxTaskDisplayStatus) : InboxAction + data class SessionFilterToggled(val filter: InboxSessionFilter) : InboxAction + data class TaskExpandToggled(val taskId: String) : InboxAction + data object FilterClicked : InboxAction + data object FilterDismissed : InboxAction + data object RetryClicked : InboxAction +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxMvi.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxModels.kt similarity index 66% rename from feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxMvi.kt rename to feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxModels.kt index 95d57e74..0534f4d4 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxMvi.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxModels.kt @@ -72,32 +72,3 @@ data class InboxTaskUiModel( val displayStatus: InboxTaskDisplayStatus, val sessions: List, ) - -// ─── State & Actions ────────────────────────────────────────────────────────── - -data class InboxUiState( - val isLoading: Boolean = true, - val isError: Boolean = false, - /** All inbox tasks fetched from the server — unfiltered. */ - val allTasks: List = emptyList(), - val searchQuery: String = "", - /** Active task-status filter chips. Empty = show all. */ - val activeStatusFilters: Set = emptySet(), - /** Active session-display filter chips. Empty = show all. */ - val activeSessionFilters: Set = emptySet(), - /** The id of the task card currently expanded to show sessions. */ - val expandedTaskId: String? = null, - /** Tasks visible after applying search and filter. */ - val visibleTasks: List = emptyList(), - val showFilterSheet: Boolean = false, -) - -sealed interface InboxAction { - data class SearchQueryChanged(val query: String) : InboxAction - data class StatusFilterToggled(val filter: InboxTaskDisplayStatus) : InboxAction - data class SessionFilterToggled(val filter: InboxSessionFilter) : InboxAction - data class TaskExpandToggled(val taskId: String) : InboxAction - data object FilterClicked : InboxAction - data object FilterDismissed : InboxAction - data object RetryClicked : InboxAction -} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxState.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxState.kt new file mode 100644 index 00000000..cc90794e --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/InboxState.kt @@ -0,0 +1,18 @@ +package com.awan.feature.goals.impl.presentation + +data class InboxUiState( + val isLoading: Boolean = true, + val isError: Boolean = false, + /** All inbox tasks fetched from the server — unfiltered. */ + val allTasks: List = emptyList(), + val searchQuery: String = "", + /** Active task-status filter chips. Empty = show all. */ + val activeStatusFilters: Set = emptySet(), + /** Active session-display filter chips. Empty = show all. */ + val activeSessionFilters: Set = emptySet(), + /** The id of the task card currently expanded to show sessions. */ + val expandedTaskId: String? = null, + /** Tasks visible after applying search and filter. */ + val visibleTasks: List = emptyList(), + val showFilterSheet: Boolean = false, +) diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt index 7e57e679..4eae04b2 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt @@ -44,26 +44,42 @@ import com.awan.app.core.model.Task import com.awan.app.core.model.TaskStatus import com.awan.feature.goals.impl.R import com.awan.feature.goals.impl.presentation.GoalDetailsAction +import com.awan.feature.goals.impl.presentation.GoalDetailsEvent import com.awan.feature.goals.impl.presentation.GoalDetailsState import com.awan.feature.goals.impl.ui.components.goalAccentColor import com.composables.icons.lucide.Calendar import com.composables.icons.lucide.Link import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Plus import com.composables.icons.lucide.Tag +import com.composables.icons.lucide.Trash2 +import kotlinx.coroutines.flow.Flow @Composable fun GoalDetailsScreen( state: GoalDetailsState, + events: Flow, onAction: (GoalDetailsAction) -> Unit, + onNavigateBack: () -> Unit, + onOpenAddTask: (String) -> Unit, modifier: Modifier = Modifier ) { val colors = AwanTheme.colors + ObserveAsEvents(events) { event -> + when (event) { + GoalDetailsEvent.NavigateBack -> onNavigateBack() + is GoalDetailsEvent.OpenAddTask -> onOpenAddTask(event.goalId) + } + } + Scaffold( topBar = { GoalDetailsTopBar( title = state.goal?.title ?: "", - onBack = { onAction(GoalDetailsAction.Back) } + onBack = { onAction(GoalDetailsAction.Back) }, + onDeleteClick = { onAction(GoalDetailsAction.DeleteClicked) }, + onAddTaskClick = { onAction(GoalDetailsAction.AddTaskClicked) } ) }, containerColor = colors.background, @@ -95,7 +111,9 @@ fun GoalDetailsScreen( @Composable private fun GoalDetailsTopBar( title: String, - onBack: () -> Unit + onBack: () -> Unit, + onDeleteClick: () -> Unit, + onAddTaskClick: () -> Unit, ) { val colors = AwanTheme.colors Column( @@ -128,20 +146,51 @@ private fun GoalDetailsTopBar( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - AwanBackButton(onClick = onBack) - Spacer(modifier = Modifier.width(12.dp)) - AwanText( - text = title, - style = AwanTheme.typography.title.copy( - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = colors.ink - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + AwanBackButton(onClick = onBack) + Spacer(modifier = Modifier.width(12.dp)) + AwanText( + text = title, + style = AwanTheme.typography.title.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = colors.ink + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AwanIconButton( + onClick = onAddTaskClick, + contentDescription = "Add Task", + icon = { + Icon( + imageVector = Lucide.Plus, + contentDescription = null, + tint = colors.sky, + modifier = Modifier.size(20.dp) + ) + } + ) + + AwanIconButton( + onClick = onDeleteClick, + contentDescription = "Delete Goal", + icon = { + Icon( + imageVector = Lucide.Trash2, + contentDescription = null, + tint = colors.destructive, + modifier = Modifier.size(20.dp) + ) + } + ) + } } } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalCard.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalCard.kt index 1efb6dbc..0c3f2dd3 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalCard.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalCard.kt @@ -35,7 +35,6 @@ import com.awan.app.core.designsystem.AwanText import com.awan.app.core.designsystem.AwanTheme import com.awan.app.core.model.Goal import com.awan.feature.goals.impl.R -import com.awan.feature.goals.impl.presentation.moscowPriority import com.composables.icons.lucide.Check import com.composables.icons.lucide.ChevronRight import com.composables.icons.lucide.Lucide diff --git a/feature/goals/impl/src/main/res/values-ar/strings.xml b/feature/goals/impl/src/main/res/values-ar/strings.xml index e9f2c58a..173e04a7 100644 --- a/feature/goals/impl/src/main/res/values-ar/strings.xml +++ b/feature/goals/impl/src/main/res/values-ar/strings.xml @@ -7,7 +7,6 @@ %1$d مهام %1$d مستقل %1$d تابع - أهدافي %1$d من %2$d مهمة منجزة %1$d%% لا توجد أهداف نشطة بعد From 245eecc3aa950814fb51d97782eeeff69fa2dfa0 Mon Sep 17 00:00:00 2001 From: esraaehab333 Date: Tue, 11 Aug 2026 15:13:11 +0300 Subject: [PATCH 3/5] fix: address goal sync, details state, and UI issues --- app/src/main/java/com/awan/app/AwanApp.kt | 1 - .../app/core/data/goal/GoalRepositoryImpl.kt | 64 ++++++- .../data/goal/remote/GoalRemoteDataSource.kt | 2 + .../goal/remote/GoalRemoteDataSourceImpl.kt | 9 + .../awan/app/core/data/task/TaskMappers.kt | 16 +- .../com/awan/app/core/database/dao/TaskDao.kt | 3 + .../domain/goal/repository/GoalRepository.kt | 9 + .../goal/usecase/ObserveGoalsUseCase.kt | 12 ++ .../domain/goal/usecase/UpdateGoalUseCase.kt | 18 ++ .../app/core/network/api/GoalApiService.kt | 8 + .../network/dto/goal/UpdateGoalRequest.kt | 12 ++ .../presentation/AddTaskViewModelTest.kt | 12 ++ .../impl/navigation/GoalsEntryProvider.kt | 4 +- .../impl/presentation/GoalDetailsAction.kt | 15 ++ .../impl/presentation/GoalDetailsEvent.kt | 5 + .../goals/impl/presentation/GoalDetailsMvi.kt | 16 -- .../impl/presentation/GoalDetailsState.kt | 13 ++ .../impl/presentation/GoalDetailsViewModel.kt | 73 +++++-- .../goals/impl/presentation/GoalsViewModel.kt | 22 ++- .../goals/impl/ui/GoalDetailsScreen.kt | 178 +++++++++--------- .../awan/feature/goals/impl/ui/GoalsScreen.kt | 167 +++++++++++----- .../goals/impl/ui/components/GoalEditSheet.kt | 153 +++++++++++++++ .../impl/ui/components/GoalsSearchBar.kt | 9 +- .../awan/feature/home/impl/ui/HomeModels.kt | 48 +++++ .../awan/feature/home/impl/ui/HomeScreen.kt | 6 - .../awan/feature/home/impl/ui/HomeUiState.kt | 35 ---- 26 files changed, 672 insertions(+), 238 deletions(-) create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/ObserveGoalsUseCase.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/UpdateGoalUseCase.kt create mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/goal/UpdateGoalRequest.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsAction.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsEvent.kt delete mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsState.kt create mode 100644 feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalEditSheet.kt create mode 100644 feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeModels.kt diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt index f0bdf9b7..6faaf5c0 100644 --- a/app/src/main/java/com/awan/app/AwanApp.kt +++ b/app/src/main/java/com/awan/app/AwanApp.kt @@ -246,7 +246,6 @@ fun AwanApp( goalsEntry( onNavigateToGoalDetails = { id -> navigator.navigate(com.awan.feature.goals.api.GoalDetailsRoute(id)) }, onBack = { navigator.goBack() }, - onOpenAddTask = { _ -> showAddTask = true } ) aiTasksEntry(onBack = { navigator.goBack() }) inventoryEntry(onBack = { navigator.goBack() }) diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt index 95b5c8b7..d416ec1e 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/goal/GoalRepositoryImpl.kt @@ -18,14 +18,20 @@ import com.awan.app.core.model.ProposedGoalSession import com.awan.app.core.network.dto.GoalDecomposeRequest import com.awan.app.core.network.dto.goal.ConfirmAiScheduleRequest import com.awan.app.core.network.dto.goal.CreateGoalRequest +import com.awan.app.core.network.dto.goal.UpdateGoalRequest import com.awan.app.core.network.dto.goal.ProposedGoalSessionDto import com.awan.app.core.network.dto.GoalInfoResponse import com.awan.app.core.data.sync.SyncTtl import com.awan.app.core.database.model.GoalEntity +import com.awan.app.core.database.model.TaskDependencyEntity +import com.awan.app.core.data.task.toDependencyEntities import com.awan.app.core.data.task.toEntity import com.awan.app.core.data.task.toTaskModel import com.awan.app.core.common.result.suspendOnSuccess import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import javax.inject.Inject @@ -45,6 +51,17 @@ class GoalRepositoryImpl @Inject constructor( @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : GoalRepository { + override fun observeGoals(): Flow> { + return goalDao.observeAllGoals().map { entities -> + entities.map { entity -> + val tasks = taskDao.getTasksByGoal(entity.id).map { taskEntity -> + taskEntity.toTaskModel(dependsOnTaskIds = taskDao.getDependsOnIds(taskEntity.id)) + } + entity.toModel().copy(tasks = tasks) + } + } + } + override suspend fun getGoals(): Result> = withContext(ioDispatcher) { if (connectivityMonitor.isCurrentlyOnline()) { remoteDataSource.getGoals().suspendOnSuccess { syncGoals(it) } @@ -87,6 +104,37 @@ class GoalRepositoryImpl @Inject constructor( } ?: Result.Error(AppError.NotFound) } + override suspend fun updateGoal( + goalId: String, + title: String?, + description: String?, + status: String?, + targetDate: String?, + ): Result = withContext(ioDispatcher) { + if (!connectivityMonitor.isCurrentlyOnline()) { + return@withContext Result.Error(AppError.Network) + } + + // Restriction: Inbox cannot be edited + val existing = goalDao.getGoal(goalId) + if (existing?.isInbox == true) { + return@withContext Result.Error(AppError.Unknown(Throwable("Inbox goal cannot be edited"))) + } + + remoteDataSource.updateGoal( + goalId = goalId, + request = UpdateGoalRequest( + title = title, + description = description, + status = status, + targetDate = targetDate, + ), + ).map { dto -> + syncGoal(dto) + dto.toEntity().toModelWithTasks() + } + } + override suspend fun deleteGoal(goalId: String): Result = withContext(ioDispatcher) { if (!connectivityMonitor.isCurrentlyOnline()) { return@withContext Result.Error(AppError.Network) @@ -127,25 +175,27 @@ class GoalRepositoryImpl @Inject constructor( } private suspend fun GoalEntity.toModelWithTasks(): Goal { - val tasks = taskDao.getTasksByGoal(id).map { it.toTaskModel() } + val tasks = taskDao.getTasksByGoal(id).map { taskEntity -> + taskEntity.toTaskModel(dependsOnTaskIds = taskDao.getDependsOnIds(taskEntity.id)) + } return toModel().copy(tasks = tasks) } private suspend fun syncGoal(dto: GoalInfoResponse) { val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) goalDao.upsertGoal(dto.toEntity().copy(expiryTime = expiry)) - if (dto.tasks.isNotEmpty()) { - taskDao.upsertTasks(dto.tasks.map { it.toEntity(expiryTime = expiry) }) - } + val entities = dto.tasks.map { it.toEntity(expiryTime = expiry) } + val dependencies = dto.tasks.flatMap { it.toDependencyEntities() } + taskDao.replaceTasksForGoal(dto.id, entities, dependencies) } private suspend fun syncGoals(dtos: List) { val expiry = SyncTtl.computeExpiry(SyncTtl.GOALS_TTL_MS) goalDao.upsertGoals(dtos.map { it.toEntity().copy(expiryTime = expiry) }) dtos.forEach { dto -> - if (dto.tasks.isNotEmpty()) { - taskDao.upsertTasks(dto.tasks.map { it.toEntity(expiryTime = expiry) }) - } + val entities = dto.tasks.map { it.toEntity(expiryTime = expiry) } + val dependencies = dto.tasks.flatMap { it.toDependencyEntities() } + taskDao.replaceTasksForGoal(dto.id, entities, dependencies) } } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/goal/remote/GoalRemoteDataSource.kt b/core/data/src/main/kotlin/com/awan/app/core/data/goal/remote/GoalRemoteDataSource.kt index 66f6e1bc..ec9d37ff 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/goal/remote/GoalRemoteDataSource.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/goal/remote/GoalRemoteDataSource.kt @@ -7,6 +7,7 @@ import com.awan.app.core.network.dto.GoalInfoResponse import com.awan.app.core.network.dto.goal.AiGoalScheduleProposalResponse import com.awan.app.core.network.dto.goal.ConfirmAiScheduleRequest import com.awan.app.core.network.dto.goal.CreateGoalRequest +import com.awan.app.core.network.dto.goal.UpdateGoalRequest import com.awan.app.core.network.dto.goal.GoalDecompositionTranscriptResponse import com.awan.app.core.network.dto.goal.ScheduleGoalRequest import com.awan.app.core.network.dto.task.TaskScheduleResponse @@ -16,6 +17,7 @@ interface GoalRemoteDataSource { suspend fun createGoal(request: CreateGoalRequest): Result suspend fun getInboxGoal(): Result suspend fun getGoal(goalId: String): Result + suspend fun updateGoal(goalId: String, request: UpdateGoalRequest): Result suspend fun deleteGoal(goalId: String): Result suspend fun continueDecomposition(request: GoalDecomposeRequest): Result suspend fun confirmDecomposition(sessionId: String): Result diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/goal/remote/GoalRemoteDataSourceImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/goal/remote/GoalRemoteDataSourceImpl.kt index 06c5b732..c0ebbfd6 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/goal/remote/GoalRemoteDataSourceImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/goal/remote/GoalRemoteDataSourceImpl.kt @@ -10,6 +10,7 @@ import com.awan.app.core.network.dto.GoalInfoResponse import com.awan.app.core.network.dto.goal.AiGoalScheduleProposalResponse import com.awan.app.core.network.dto.goal.ConfirmAiScheduleRequest import com.awan.app.core.network.dto.goal.CreateGoalRequest +import com.awan.app.core.network.dto.goal.UpdateGoalRequest import com.awan.app.core.network.dto.goal.GoalDecompositionTranscriptResponse import com.awan.app.core.network.dto.goal.ScheduleGoalRequest import com.awan.app.core.network.dto.task.TaskScheduleResponse @@ -44,6 +45,14 @@ class GoalRemoteDataSourceImpl @Inject constructor( goalApiService.getGoal(goalId) } + override suspend fun updateGoal( + goalId: String, + request: UpdateGoalRequest, + ): Result = + safeApiCall(dispatcher = ioDispatcher, json = json) { + goalApiService.updateGoal(goalId, request) + } + override suspend fun deleteGoal(goalId: String): Result = safeApiCall(dispatcher = ioDispatcher, json = json) { goalApiService.deleteGoal(goalId) diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/task/TaskMappers.kt b/core/data/src/main/kotlin/com/awan/app/core/data/task/TaskMappers.kt index a81ffcce..3e415910 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/task/TaskMappers.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/task/TaskMappers.kt @@ -69,7 +69,9 @@ internal fun TaskInfoResponse.toTaskModel(): Task = Task( category = category?.toModel(), ) -internal fun com.awan.app.core.database.model.TaskEntity.toTaskModel(): Task = Task( +internal fun com.awan.app.core.database.model.TaskEntity.toTaskModel( + dependsOnTaskIds: List = emptyList() +): Task = Task( id = id, title = title, description = description, @@ -79,7 +81,7 @@ internal fun com.awan.app.core.database.model.TaskEntity.toTaskModel(): Task = T estimatedPoints = estimatedPoints, allowTaskSplitting = allowTaskSplitting, goalId = goalId, - dependsOnTaskIds = emptyList(), // Dependencies are stored separately in Room + dependsOnTaskIds = dependsOnTaskIds, ) internal fun TaskProposalResponse.toModel(): TaskProposals = TaskProposals( @@ -208,6 +210,16 @@ internal fun TaskInfoResponse.toEntity( expiryTime = expiryTime, ) +internal fun TaskInfoResponse.toDependencyEntities(): List { + return dependsOnTaskIds?.map { prerequisiteId -> + com.awan.app.core.database.model.TaskDependencyEntity( + taskId = id, + dependsOnTaskId = prerequisiteId + ) + } ?: emptyList() +} + + internal fun com.awan.app.core.network.dto.session.SessionDto.toEntity( taskId: String, date: String, diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/dao/TaskDao.kt b/core/database/src/main/kotlin/com/awan/app/core/database/dao/TaskDao.kt index 49cd35f6..e490b65b 100644 --- a/core/database/src/main/kotlin/com/awan/app/core/database/dao/TaskDao.kt +++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/TaskDao.kt @@ -51,6 +51,9 @@ interface TaskDao { @Query("SELECT dependsOnTaskId FROM task_dependencies WHERE taskId = :taskId") fun observeDependsOnIds(taskId: String): Flow> + @Query("SELECT dependsOnTaskId FROM task_dependencies WHERE taskId = :taskId") + fun getDependsOnIds(taskId: String): List + /** Returns IDs of all tasks that depend on [taskId] (blocked by this task). */ @Query("SELECT taskId FROM task_dependencies WHERE dependsOnTaskId = :taskId") fun observeDependentIds(taskId: String): Flow> diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/repository/GoalRepository.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/repository/GoalRepository.kt index 883a9d4c..3ee01dc4 100644 --- a/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/repository/GoalRepository.kt +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/repository/GoalRepository.kt @@ -6,12 +6,21 @@ import com.awan.app.core.model.GoalDecompositionReply import com.awan.app.core.model.GoalDecompositionTranscript import com.awan.app.core.model.GoalScheduleProposal import com.awan.app.core.model.ProposedGoalSession +import kotlinx.coroutines.flow.Flow interface GoalRepository { + fun observeGoals(): Flow> suspend fun getGoals(): Result> suspend fun createGoal(title: String, description: String?, targetDate: String?): Result suspend fun getInboxGoal(): Result suspend fun getGoal(goalId: String): Result + suspend fun updateGoal( + goalId: String, + title: String? = null, + description: String? = null, + status: String? = null, + targetDate: String? = null, + ): Result suspend fun deleteGoal(goalId: String): Result suspend fun continueDecomposition( sessionId: String?, diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/ObserveGoalsUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/ObserveGoalsUseCase.kt new file mode 100644 index 00000000..520ca7f6 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/ObserveGoalsUseCase.kt @@ -0,0 +1,12 @@ +package com.awan.app.core.domain.goal.usecase + +import com.awan.app.core.domain.goal.repository.GoalRepository +import com.awan.app.core.model.Goal +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class ObserveGoalsUseCase @Inject constructor( + private val repository: GoalRepository, +) { + operator fun invoke(): Flow> = repository.observeGoals() +} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/UpdateGoalUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/UpdateGoalUseCase.kt new file mode 100644 index 00000000..b3e72f06 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/goal/usecase/UpdateGoalUseCase.kt @@ -0,0 +1,18 @@ +package com.awan.app.core.domain.goal.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.goal.repository.GoalRepository +import com.awan.app.core.model.Goal +import javax.inject.Inject + +class UpdateGoalUseCase @Inject constructor( + private val repository: GoalRepository, +) { + suspend operator fun invoke( + goalId: String, + title: String? = null, + description: String? = null, + status: String? = null, + targetDate: String? = null, + ): Result = repository.updateGoal(goalId, title, description, status, targetDate) +} diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/api/GoalApiService.kt b/core/network/src/main/kotlin/com/awan/app/core/network/api/GoalApiService.kt index b4592d77..1d11b1b7 100644 --- a/core/network/src/main/kotlin/com/awan/app/core/network/api/GoalApiService.kt +++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/GoalApiService.kt @@ -9,11 +9,13 @@ import com.awan.app.core.network.dto.goal.ConfirmAiScheduleRequest import com.awan.app.core.network.dto.goal.CreateGoalRequest import com.awan.app.core.network.dto.goal.GoalDecompositionTranscriptResponse import com.awan.app.core.network.dto.goal.ScheduleGoalRequest +import com.awan.app.core.network.dto.goal.UpdateGoalRequest import com.awan.app.core.network.dto.task.TaskScheduleResponse import kotlinx.serialization.json.JsonObject import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query @@ -40,6 +42,12 @@ interface GoalApiService { @Query("expand") expand: Boolean = false, ): GoalInfoResponse + @PATCH("v1/goals/{goalId}") + suspend fun updateGoal( + @Path("goalId") goalId: String, + @Body request: UpdateGoalRequest, + ): GoalInfoResponse + @DELETE("v1/goals/{goalId}") suspend fun deleteGoal( @Path("goalId") goalId: String, diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/goal/UpdateGoalRequest.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/goal/UpdateGoalRequest.kt new file mode 100644 index 00000000..db122b65 --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/goal/UpdateGoalRequest.kt @@ -0,0 +1,12 @@ +package com.awan.app.core.network.dto.goal + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateGoalRequest( + @SerialName("title") val title: String? = null, + @SerialName("description") val description: String? = null, + @SerialName("status") val status: String? = null, + @SerialName("targetDate") val targetDate: String? = null, +) diff --git a/feature/add-task/src/test/java/com/awan/feature/addtask/presentation/AddTaskViewModelTest.kt b/feature/add-task/src/test/java/com/awan/feature/addtask/presentation/AddTaskViewModelTest.kt index fb65bf3f..40a1483e 100644 --- a/feature/add-task/src/test/java/com/awan/feature/addtask/presentation/AddTaskViewModelTest.kt +++ b/feature/add-task/src/test/java/com/awan/feature/addtask/presentation/AddTaskViewModelTest.kt @@ -44,6 +44,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -129,6 +130,8 @@ class AddTaskViewModelTest { val continueCalls = mutableListOf>() val confirmCalls = mutableListOf() + override fun observeGoals(): Flow> = flowOf(emptyList()) + override suspend fun getGoals(): Result> = Result.Success(emptyList()) override suspend fun continueDecomposition( @@ -147,6 +150,15 @@ class AddTaskViewModelTest { override suspend fun createGoal(title: String, description: String?, targetDate: String?): Result = error("not used") override suspend fun getInboxGoal(): Result = error("not used") override suspend fun getGoal(goalId: String): Result = error("not used") + + override suspend fun updateGoal( + goalId: String, + title: String?, + description: String?, + status: String?, + targetDate: String? + ): Result = error("not used") + override suspend fun deleteGoal(goalId: String): Result = error("not used") override suspend fun getDecompositionTranscript(sessionId: String): Result = error("not used") override suspend fun cancelDecomposition(sessionId: String): Result = error("not used") diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt index 50712130..4888ae39 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/navigation/GoalsEntryProvider.kt @@ -61,7 +61,6 @@ import com.composables.icons.lucide.Target fun EntryProviderScope.goalsEntry( onNavigateToGoalDetails: (String) -> Unit = {}, onBack: () -> Unit = {}, - onOpenAddTask: (String) -> Unit = {}, ) { entry { GoalsRouteScreen( @@ -81,8 +80,7 @@ fun EntryProviderScope.goalsEntry( state = state, events = viewModel.events, onAction = viewModel::onAction, - onNavigateBack = onBack, - onOpenAddTask = onOpenAddTask + onNavigateBack = onBack ) } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsAction.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsAction.kt new file mode 100644 index 00000000..a28e497f --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsAction.kt @@ -0,0 +1,15 @@ +package com.awan.feature.goals.impl.presentation + +sealed interface GoalDetailsAction { + data object Retry : GoalDetailsAction + data object Back : GoalDetailsAction + data object DeleteClicked : GoalDetailsAction + data object EditClicked : GoalDetailsAction + data object EditDismissed : GoalDetailsAction + data class GoalUpdated( + val title: String, + val description: String?, + val status: String, + val targetDate: String? + ) : GoalDetailsAction +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsEvent.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsEvent.kt new file mode 100644 index 00000000..442977bf --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsEvent.kt @@ -0,0 +1,5 @@ +package com.awan.feature.goals.impl.presentation + +sealed interface GoalDetailsEvent { + data object NavigateBack : GoalDetailsEvent +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt deleted file mode 100644 index 338a973e..00000000 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsMvi.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.awan.feature.goals.impl.presentation - -import com.awan.app.core.model.Goal - -data class GoalDetailsState( - val isLoading: Boolean = true, - val goal: Goal? = null, - val error: String? = null, -) - -sealed interface GoalDetailsAction { - data object Retry : GoalDetailsAction - data object Back : GoalDetailsAction - data object DeleteClicked : GoalDetailsAction - data object AddTaskClicked : GoalDetailsAction -} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsState.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsState.kt new file mode 100644 index 00000000..5c5db10b --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsState.kt @@ -0,0 +1,13 @@ +package com.awan.feature.goals.impl.presentation + +import com.awan.app.core.common.text.UiText +import com.awan.app.core.model.Goal + +data class GoalDetailsState( + val isLoading: Boolean = true, + val goal: Goal? = null, + val error: UiText? = null, + val isDeleting: Boolean = false, + val isUpdating: Boolean = false, + val showEditSheet: Boolean = false, +) diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt index b81077fc..202ba62c 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt @@ -1,11 +1,16 @@ package com.awan.feature.goals.impl.presentation +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.awan.app.core.common.error.toUiText import com.awan.app.core.common.result.Result +import com.awan.app.core.common.text.UiText import com.awan.app.core.domain.goal.usecase.DeleteGoalUseCase import com.awan.app.core.domain.goal.usecase.GetGoalUseCase +import com.awan.app.core.domain.goal.usecase.UpdateGoalUseCase import com.awan.app.core.model.Goal +import com.awan.feature.goals.impl.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -16,23 +21,26 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -sealed interface GoalDetailsEvent { - data object NavigateBack : GoalDetailsEvent - data class OpenAddTask(val goalId: String) : GoalDetailsEvent -} - @HiltViewModel class GoalDetailsViewModel @Inject constructor( private val getGoalUseCase: GetGoalUseCase, + private val updateGoalUseCase: UpdateGoalUseCase, private val deleteGoalUseCase: DeleteGoalUseCase, + savedStateHandle: SavedStateHandle, ) : ViewModel() { + private val goalId: String = checkNotNull(savedStateHandle["id"]) + private val _state = MutableStateFlow(GoalDetailsState()) val state: StateFlow = _state.asStateFlow() private val _events = Channel(Channel.BUFFERED) val events = _events.receiveAsFlow() + init { + loadGoal(goalId) + } + fun loadGoal(id: String) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } @@ -40,9 +48,16 @@ class GoalDetailsViewModel @Inject constructor( is Result.Success -> { _state.update { it.copy(isLoading = false, goal = result.data) } } + is Result.Error -> { - _state.update { it.copy(isLoading = false, error = "Failed to load goal") } + _state.update { + it.copy( + isLoading = false, + error = result.error.toUiText() + ) + } } + Result.Loading -> {} } } @@ -51,7 +66,7 @@ class GoalDetailsViewModel @Inject constructor( fun onAction(action: GoalDetailsAction) { when (action) { GoalDetailsAction.Retry -> { - _state.value.goal?.id?.let { loadGoal(it) } + loadGoal(goalId) } GoalDetailsAction.Back -> { @@ -61,27 +76,51 @@ class GoalDetailsViewModel @Inject constructor( } GoalDetailsAction.DeleteClicked -> deleteGoal() + GoalDetailsAction.EditClicked -> _state.update { it.copy(showEditSheet = true) } + GoalDetailsAction.EditDismissed -> _state.update { it.copy(showEditSheet = false) } + is GoalDetailsAction.GoalUpdated -> updateGoal(action) + } + } - GoalDetailsAction.AddTaskClicked -> { - _state.value.goal?.id?.let { - viewModelScope.launch { - _events.send(GoalDetailsEvent.OpenAddTask(it)) - } + private fun deleteGoal() { + val goalId = _state.value.goal?.id ?: return + viewModelScope.launch { + _state.update { it.copy(isDeleting = true) } + when (val result = deleteGoalUseCase(goalId)) { + is Result.Success -> { + _events.send(GoalDetailsEvent.NavigateBack) } + is Result.Error -> { + _state.update { it.copy(isDeleting = false, error = result.error.toUiText()) } + } + Result.Loading -> {} } } } - private fun deleteGoal() { + private fun updateGoal(action: GoalDetailsAction.GoalUpdated) { val goalId = _state.value.goal?.id ?: return viewModelScope.launch { - _state.update { it.copy(isLoading = true) } - when (deleteGoalUseCase(goalId)) { + _state.update { it.copy(isUpdating = true) } + val result = updateGoalUseCase( + goalId = goalId, + title = action.title, + description = action.description, + status = action.status, + targetDate = action.targetDate, + ) + when (result) { is Result.Success -> { - _events.send(GoalDetailsEvent.NavigateBack) + _state.update { + it.copy( + isUpdating = false, + showEditSheet = false, + goal = result.data + ) + } } is Result.Error -> { - _state.update { it.copy(isLoading = false, error = "Failed to delete goal") } + _state.update { it.copy(isUpdating = false, error = result.error.toUiText()) } } Result.Loading -> {} } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt index 48cea179..16ce6937 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.awan.app.core.common.result.Result import com.awan.app.core.domain.goal.usecase.GetGoalsUseCase +import com.awan.app.core.domain.goal.usecase.ObserveGoalsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -17,6 +18,7 @@ import javax.inject.Inject @HiltViewModel class GoalsViewModel @Inject constructor( private val getGoalsUseCase: GetGoalsUseCase, + private val observeGoalsUseCase: ObserveGoalsUseCase, ) : ViewModel() { private val _state = MutableStateFlow(GoalsState()) @@ -26,13 +28,14 @@ class GoalsViewModel @Inject constructor( val events = _events.receiveAsFlow() init { - loadGoals() + observeGoals() + refreshGoals() } fun onAction(action: GoalsAction) { when (action) { is GoalsAction.SearchQueryChanged -> _state.update { it.copy(searchQuery = action.query) } - GoalsAction.RetryClicked -> loadGoals() + GoalsAction.RetryClicked -> refreshGoals() is GoalsAction.GoalClicked -> { viewModelScope.launch { _events.send(GoalsEvent.NavigateToGoalDetails(action.goalId)) @@ -43,7 +46,15 @@ class GoalsViewModel @Inject constructor( } } - private fun loadGoals() { + private fun observeGoals() { + viewModelScope.launch { + observeGoalsUseCase().collect { goals -> + _state.update { it.copy(goals = goals) } + } + } + } + + private fun refreshGoals() { viewModelScope.launch { _state.update { it.copy(isLoading = true, isError = false) } @@ -53,16 +64,13 @@ class GoalsViewModel @Inject constructor( it.copy( isLoading = false, isError = false, - goals = result.data, ) } } is Result.Error -> { _state.update { it.copy(isLoading = false, isError = true) } } - Result.Loading -> { - // Handled before invoke - } + Result.Loading -> {} } } } diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt index 4eae04b2..9bd5e595 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt @@ -27,7 +27,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -46,12 +47,11 @@ import com.awan.feature.goals.impl.R import com.awan.feature.goals.impl.presentation.GoalDetailsAction import com.awan.feature.goals.impl.presentation.GoalDetailsEvent import com.awan.feature.goals.impl.presentation.GoalDetailsState +import com.awan.feature.goals.impl.ui.components.GoalEditSheet import com.awan.feature.goals.impl.ui.components.goalAccentColor import com.composables.icons.lucide.Calendar -import com.composables.icons.lucide.Link import com.composables.icons.lucide.Lucide -import com.composables.icons.lucide.Plus -import com.composables.icons.lucide.Tag +import com.composables.icons.lucide.Pencil import com.composables.icons.lucide.Trash2 import kotlinx.coroutines.flow.Flow @@ -61,7 +61,6 @@ fun GoalDetailsScreen( events: Flow, onAction: (GoalDetailsAction) -> Unit, onNavigateBack: () -> Unit, - onOpenAddTask: (String) -> Unit, modifier: Modifier = Modifier ) { val colors = AwanTheme.colors @@ -69,7 +68,6 @@ fun GoalDetailsScreen( ObserveAsEvents(events) { event -> when (event) { GoalDetailsEvent.NavigateBack -> onNavigateBack() - is GoalDetailsEvent.OpenAddTask -> onOpenAddTask(event.goalId) } } @@ -78,8 +76,7 @@ fun GoalDetailsScreen( GoalDetailsTopBar( title = state.goal?.title ?: "", onBack = { onAction(GoalDetailsAction.Back) }, - onDeleteClick = { onAction(GoalDetailsAction.DeleteClicked) }, - onAddTaskClick = { onAction(GoalDetailsAction.AddTaskClicked) } + onEditClick = { onAction(GoalDetailsAction.EditClicked) } ) }, containerColor = colors.background, @@ -94,17 +91,32 @@ fun GoalDetailsScreen( ) } state.goal != null -> { - GoalDetailsContent(goal = state.goal) + GoalDetailsContent( + goal = state.goal, + isDeleting = state.isDeleting, + onDeleteClick = { onAction(GoalDetailsAction.DeleteClicked) } + ) } state.error != null -> { AwanText( - text = state.error, + text = state.error.asString(), modifier = Modifier.align(Alignment.Center), style = AwanTheme.typography.body ) } } } + + if (state.showEditSheet && state.goal != null) { + GoalEditSheet( + goal = state.goal, + isSaving = state.isUpdating, + onDismiss = { onAction(GoalDetailsAction.EditDismissed) }, + onConfirm = { title, description, status, targetDate -> + onAction(GoalDetailsAction.GoalUpdated(title, description, status, targetDate)) + } + ) + } } } @@ -112,94 +124,55 @@ fun GoalDetailsScreen( private fun GoalDetailsTopBar( title: String, onBack: () -> Unit, - onDeleteClick: () -> Unit, - onAddTaskClick: () -> Unit, + onEditClick: () -> Unit ) { val colors = AwanTheme.colors - Column( + Row( modifier = Modifier .fillMaxWidth() - .background( - Brush.verticalGradient( - listOf(colors.backgroundStart, colors.background) - ) - ) + .background(colors.background) .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(140.dp), - contentAlignment = Alignment.Center - ) { - AwanCloudsHorizon( - modifier = Modifier.fillMaxSize() - ) - AwanMascot( - expression = MascotExpression.Curious, - width = 110.dp, - blinkEnabled = true + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + AwanBackButton(onClick = onBack) + Spacer(modifier = Modifier.width(12.dp)) + AwanText( + text = title, + style = AwanTheme.typography.title.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = colors.ink + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { - AwanBackButton(onClick = onBack) - Spacer(modifier = Modifier.width(12.dp)) - AwanText( - text = title, - style = AwanTheme.typography.title.copy( - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = colors.ink - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis + AwanIconButton( + onClick = onEditClick, + contentDescription = "Edit Goal", + icon = { + Icon( + imageVector = Lucide.Pencil, + contentDescription = null, + tint = colors.sky, + modifier = Modifier.size(20.dp) ) } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - AwanIconButton( - onClick = onAddTaskClick, - contentDescription = "Add Task", - icon = { - Icon( - imageVector = Lucide.Plus, - contentDescription = null, - tint = colors.sky, - modifier = Modifier.size(20.dp) - ) - } - ) - - AwanIconButton( - onClick = onDeleteClick, - contentDescription = "Delete Goal", - icon = { - Icon( - imageVector = Lucide.Trash2, - contentDescription = null, - tint = colors.destructive, - modifier = Modifier.size(20.dp) - ) - } - ) - } - } + ) } } @Composable private fun GoalDetailsContent( - goal: Goal + goal: Goal, + isDeleting: Boolean, + onDeleteClick: () -> Unit ) { - val accentColor = goalAccentColor(0) // Default for now + val accentColor = goalAccentColor(goal.id.hashCode()) LazyColumn( modifier = Modifier.fillMaxSize(), @@ -226,6 +199,19 @@ private fun GoalDetailsContent( accentColor = accentColor ) } + + item { + Spacer(modifier = Modifier.height(24.dp)) + AwanButton( + onClick = onDeleteClick, + modifier = Modifier.fillMaxWidth(), + variant = AwanButtonVariant.Destructive, + isLoading = isDeleting, + icon = Lucide.Trash2 + ) { + AwanText(text = "Remove Goal") + } + } item { Spacer(modifier = Modifier.height(80.dp)) } } @@ -262,7 +248,7 @@ private fun GoalHeaderCard(goal: Goal) { .padding(horizontal = 8.dp, vertical = 4.dp) ) { AwanText( - text = stringResource(R.string.goals_status_active), + text = goal.status.name, style = AwanTheme.typography.caption.copy( fontSize = 11.sp, fontWeight = FontWeight.Bold, @@ -388,8 +374,10 @@ private fun GoalTasksHeader(goal: Goal) { color = colors.ink ) ) - val independent = stringResource(R.string.goals_independent_count, 1) // TODO: real logic - val dependent = stringResource(R.string.goals_dependent_count, goal.totalTasks - 1) + val dependentCount = goal.tasks.count { it.dependsOnTaskIds.isNotEmpty() } + val independentCount = goal.totalTasks - dependentCount + val independent = stringResource(R.string.goals_independent_count, independentCount) + val dependent = stringResource(R.string.goals_dependent_count, dependentCount) AwanText( text = stringResource(R.string.goals_tasks_summary_format, independent, dependent), style = AwanTheme.typography.caption.copy( @@ -434,7 +422,18 @@ private fun GoalTaskTimelineItem( // Timeline Column Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.width(32.dp) + modifier = Modifier + .width(32.dp) + .drawBehind { + if (!isLast) { + drawLine( + color = colors.line, + start = Offset(size.width / 2, 32.dp.toPx()), + end = Offset(size.width / 2, size.height), + strokeWidth = 2.dp.toPx() + ) + } + } ) { Box( modifier = Modifier @@ -452,15 +451,6 @@ private fun GoalTaskTimelineItem( ) ) } - - if (!isLast) { - Box( - modifier = Modifier - .width(2.dp) - .height(60.dp) // Adjust based on content - .background(colors.line) - ) - } } // Content Column diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsScreen.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsScreen.kt index e7eff9fb..7616b906 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsScreen.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalsScreen.kt @@ -1,5 +1,10 @@ package com.awan.feature.goals.impl.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,20 +18,28 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource +import com.awan.app.core.designsystem.AwanButton +import com.awan.app.core.designsystem.AwanButtonVariant import com.awan.app.core.designsystem.AwanText import com.awan.app.core.designsystem.AwanTheme +import com.awan.app.core.model.Goal +import com.awan.app.core.model.GoalStatus import com.awan.feature.goals.impl.R import com.awan.feature.goals.impl.presentation.GoalsAction import com.awan.feature.goals.impl.presentation.GoalsState @@ -42,6 +55,9 @@ fun GoalsScreen( ) { val colors = AwanTheme.colors + var showAllActive by remember { mutableStateOf(false) } + var showAllAchieved by remember { mutableStateOf(false) } + Box( modifier = modifier.fillMaxSize(), ) { @@ -58,43 +74,6 @@ fun GoalsScreen( Spacer(modifier = Modifier.height(24.dp)) - // ── Goals Header ─────────────────────────────────────────────── - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 16.dp) - ) { - AwanText( - text = stringResource(R.string.goals_title_count), - style = AwanTheme.typography.title.copy( - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = colors.ink - ) - ) - - if (state.goals.isNotEmpty()) { - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = Modifier - .size(20.dp) - .clip(CircleShape) - .background(colors.sky.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center - ) { - AwanText( - text = state.goals.size.toString(), - style = AwanTheme.typography.caption.copy( - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = colors.sky - ) - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - // ── Content area ────────────────────────────────────────────────── when { state.isLoading -> { @@ -114,25 +93,41 @@ fun GoalsScreen( } else -> { - val goals = state.filteredGoals + val filteredGoals = state.filteredGoals + val activeGoals = filteredGoals.filter { it.status == GoalStatus.ACTIVE } + val achievedGoals = filteredGoals.filter { it.status == GoalStatus.ACHIEVED } - if (goals.isEmpty()) { + if (filteredGoals.isEmpty()) { GoalsEmptyState() } else { + val activeTitle = stringResource(R.string.goals_status_active) + val achievedTitle = stringResource(R.string.goals_status_completed) + LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - itemsIndexed(goals, key = { _, goal -> goal.id }) { index, goal -> - GoalCard( - goal = goal, - accentColor = goalAccentColor(index), - onClick = { onAction(GoalsAction.GoalClicked(goal.id)) } - ) - } - item { Spacer(modifier = Modifier.height(16.dp)) } + goalSection( + title = activeTitle, + goals = activeGoals, + isExpanded = showAllActive, + onToggleExpand = { showAllActive = !showAllActive }, + onGoalClick = { onAction(GoalsAction.GoalClicked(it)) } + ) + + item { Spacer(modifier = Modifier.height(8.dp)) } + + goalSection( + title = achievedTitle, + goals = achievedGoals, + isExpanded = showAllAchieved, + onToggleExpand = { showAllAchieved = !showAllAchieved }, + onGoalClick = { onAction(GoalsAction.GoalClicked(it)) } + ) + + item { Spacer(modifier = Modifier.height(80.dp)) } } } } @@ -147,3 +142,77 @@ fun GoalsScreen( ) } } + +private fun LazyListScope.goalSection( + title: String, + goals: List, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onGoalClick: (String) -> Unit, +) { + if (goals.isEmpty()) return + + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + AwanText( + text = title, + style = AwanTheme.typography.title.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = AwanTheme.colors.ink + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(AwanTheme.colors.sky.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + AwanText( + text = goals.size.toString(), + style = AwanTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = AwanTheme.colors.sky + ) + ) + } + } + } + + val visibleGoals = if (isExpanded) goals else goals.take(2) + + itemsIndexed(visibleGoals, key = { _, goal -> goal.id }) { index, goal -> + GoalCard( + goal = goal, + accentColor = goalAccentColor(index), + onClick = { onGoalClick(goal.id) }, + modifier = Modifier.animateItem() + ) + } + + if (goals.size > 2) { + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + AwanButton( + onClick = onToggleExpand, + variant = AwanButtonVariant.Quiet, + ) { + AwanText( + text = if (isExpanded) "Show Less" else "Show More", + style = AwanTheme.typography.button.copy(fontSize = 13.sp) + ) + } + } + } + } +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalEditSheet.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalEditSheet.kt new file mode 100644 index 00000000..7b394c6c --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalEditSheet.kt @@ -0,0 +1,153 @@ +package com.awan.feature.goals.impl.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.awan.app.core.designsystem.* +import com.awan.app.core.model.Goal +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.X +import java.time.LocalDate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GoalEditSheet( + goal: Goal, + onDismiss: () -> Unit, + onConfirm: (title: String, description: String?, status: String, targetDate: String?) -> Unit, + isSaving: Boolean = false +) { + var title by remember { mutableStateOf(goal.title) } + var description by remember { mutableStateOf(goal.description ?: "") } + var status by remember { mutableStateOf(goal.status.name) } + var targetDate by remember { mutableStateOf(goal.targetDate) } + + var showDatePicker by remember { mutableStateOf(false) } + + if (showDatePicker) { + AwanDatePickerDialog( + initialDate = targetDate?.let { LocalDate.parse(it) } ?: LocalDate.now(), + confirmLabel = "Set", + cancelLabel = "Cancel", + onDismiss = { showDatePicker = false }, + onConfirm = { date -> + targetDate = date.toString() + showDatePicker = false + } + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = AwanTheme.colors.surface, + dragHandle = { BottomSheetDefaults.DragHandle(color = AwanTheme.colors.line) } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = "Edit Goal", + style = AwanTheme.styles.titleText + ) + AwanIconButton(onClick = onDismiss, contentDescription = "Close") { + Icon(Lucide.X, null, tint = AwanTheme.colors.textSecondary) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + AwanText( + text = "Title", + style = AwanTheme.typography.caption.copy(color = AwanTheme.colors.textSecondary) + ) + AwanTextField( + value = title, + onValueChange = { title = it }, + placeholder = "Enter goal title" + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + AwanText( + text = "Description", + style = AwanTheme.typography.caption.copy(color = AwanTheme.colors.textSecondary) + ) + AwanTextField( + value = description, + onValueChange = { description = it }, + placeholder = "Enter goal description", + singleLine = false + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + AwanText( + text = "Status", + style = AwanTheme.typography.caption.copy(color = AwanTheme.colors.textSecondary) + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val statuses = listOf("ACTIVE", "ACHIEVED") + statuses.forEach { s -> + FilterChip( + selected = status == s, + onClick = { status = s }, + label = { + AwanText( + text = s, + style = AwanTheme.typography.caption.copy( + color = if (status == s) AwanTheme.colors.onSky else AwanTheme.colors.textPrimary + ) + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = AwanTheme.colors.sky, + selectedLabelColor = AwanTheme.colors.onSky + ) + ) + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + AwanText( + text = "Target Date", + style = AwanTheme.typography.caption.copy(color = AwanTheme.colors.textSecondary) + ) + AwanCard( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth() + ) { + Box(modifier = Modifier.padding(16.dp)) { + AwanText(text = targetDate ?: "No date set", style = AwanTheme.typography.body) + } + } + } + + AwanButton( + onClick = { + onConfirm(title, description.takeIf { it.isNotBlank() }, status, targetDate) + }, + modifier = Modifier.fillMaxWidth(), + enabled = title.isNotBlank() && !isSaving, + isLoading = isSaving + ) { + AwanText(text = "Save Changes") + } + } + } +} diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt index 9b2599cc..44924717 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -88,7 +89,13 @@ internal fun GoalsSearchBar( ), cursorBrush = SolidColor(colors.sky), singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search) + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + // The parent usually handles onQueryChange, but if there's a specific search action + // we could trigger it here. For now, just clearing focus is standard. + } + ) ) } } diff --git a/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeModels.kt b/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeModels.kt new file mode 100644 index 00000000..5a4972d4 --- /dev/null +++ b/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeModels.kt @@ -0,0 +1,48 @@ +@file:Suppress("NewApi") + +package com.awan.feature.home.impl.ui + +import com.awan.app.core.common.text.UiText +import com.awan.app.core.model.SessionTaskDetail +import com.awan.feature.home.impl.R +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +internal fun formatSelectedDate(date: LocalDate): UiText { + val today = LocalDate.now() + val pattern = DateTimeFormatter.ofPattern("EEE, MMM d", Locale.getDefault()) + val formatted = date.format(pattern) + return when (date) { + today -> UiText.StringResource(R.string.home_date_today_format, formatted) + today.minusDays(1) -> UiText.StringResource(R.string.home_date_yesterday_format, formatted) + today.plusDays(1) -> UiText.StringResource(R.string.home_date_tomorrow_format, formatted) + else -> UiText.DynamicString(formatted) + } +} + +enum class DeleteTargetType { + SESSION, + TASK, +} + +data class SessionDetailDialogState( + val sessionId: String, + val isLoading: Boolean = true, + val detail: SessionTaskDetail? = null, + val errorMessage: UiText? = null, + val isEditing: Boolean = false, + val editTitle: String = "", + val editDescription: String = "", + val editDurationMinutes: Int = 30, + val isSaving: Boolean = false, + val showDeleteConfirmDialog: Boolean = false, + val isDeleting: Boolean = false, + val deleteTargetType: DeleteTargetType = DeleteTargetType.SESSION, +) + +sealed interface TimelineContentState { + data object Loading : TimelineContentState + data class Error(val message: UiText) : TimelineContentState + data object Ready : TimelineContentState +} diff --git a/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeScreen.kt b/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeScreen.kt index 519d01fc..2a0761b3 100644 --- a/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeScreen.kt +++ b/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeScreen.kt @@ -53,12 +53,6 @@ import com.awan.feature.home.impl.R import com.awan.feature.home.impl.ui.components.SessionTaskDetailDialog import java.time.LocalDate -private sealed interface TimelineContentState { - data object Loading : TimelineContentState - data class Error(val message: UiText) : TimelineContentState - data object Ready : TimelineContentState -} - @Composable fun HomeScreen( modifier: Modifier = Modifier, diff --git a/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeUiState.kt b/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeUiState.kt index 8b9aec13..c62c10ee 100644 --- a/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeUiState.kt +++ b/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeUiState.kt @@ -12,41 +12,6 @@ import com.awan.app.core.designsystem.ScheduleZone import com.awan.app.core.domain.gamification.model.WheelSegment import com.awan.feature.home.impl.R import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.Locale -import com.awan.app.core.model.SessionTaskDetail - -internal fun formatSelectedDate(date: LocalDate): UiText { - val today = LocalDate.now() - val pattern = DateTimeFormatter.ofPattern("EEE, MMM d", Locale.getDefault()) - val formatted = date.format(pattern) - return when (date) { - today -> UiText.StringResource(R.string.home_date_today_format, formatted) - today.minusDays(1) -> UiText.StringResource(R.string.home_date_yesterday_format, formatted) - today.plusDays(1) -> UiText.StringResource(R.string.home_date_tomorrow_format, formatted) - else -> UiText.DynamicString(formatted) - } -} - -enum class DeleteTargetType { - SESSION, - TASK, -} - -data class SessionDetailDialogState( - val sessionId: String, - val isLoading: Boolean = true, - val detail: SessionTaskDetail? = null, - val errorMessage: UiText? = null, - val isEditing: Boolean = false, - val editTitle: String = "", - val editDescription: String = "", - val editDurationMinutes: Int = 30, - val isSaving: Boolean = false, - val showDeleteConfirmDialog: Boolean = false, - val isDeleting: Boolean = false, - val deleteTargetType: DeleteTargetType = DeleteTargetType.SESSION, -) data class HomeUiState( val userName: String = "", From fab9d20d07685d4e098021ebadf39d17fcdb1ba6 Mon Sep 17 00:00:00 2001 From: Abdallah-Elsobky Date: Tue, 11 Aug 2026 15:36:10 +0300 Subject: [PATCH 4/5] fix(test): update unit test fakes with updateGoal, getDependsOnIds, and observeGoals methods --- .../goal/GoalDecompositionRepositoryTest.kt | 3 +++ .../app/core/data/goal/GoalRepositoryTest.kt | 3 +++ .../home/local/HomeLocalDataSourceTest.kt | 1 + .../data/sync/OfflineSyncCoordinatorTest.kt | 2 ++ .../data/task/AiTaskRepositoryImplTest.kt | 1 + .../core/data/task/TaskRepositoryImplTest.kt | 1 + .../impl/presentation/GoalsViewModelTest.kt | 24 +++++++++++++++---- 7 files changed, 31 insertions(+), 4 deletions(-) diff --git a/core/data/src/test/java/com/awan/app/core/data/goal/GoalDecompositionRepositoryTest.kt b/core/data/src/test/java/com/awan/app/core/data/goal/GoalDecompositionRepositoryTest.kt index d3648299..dfb06a8f 100644 --- a/core/data/src/test/java/com/awan/app/core/data/goal/GoalDecompositionRepositoryTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/goal/GoalDecompositionRepositoryTest.kt @@ -63,6 +63,7 @@ class GoalDecompositionRepositoryTest { override suspend fun createGoal(request: com.awan.app.core.network.dto.goal.CreateGoalRequest): GoalInfoResponse = error("Not implemented") override suspend fun getInboxGoal(): GoalInfoResponse = error("Not implemented") override suspend fun getGoal(goalId: String, expand: Boolean): GoalInfoResponse = error("Not implemented") + override suspend fun updateGoal(goalId: String, request: com.awan.app.core.network.dto.goal.UpdateGoalRequest): GoalInfoResponse = error("Not implemented") override suspend fun deleteGoal(goalId: String) = error("Not implemented") override suspend fun decomposeGoal( @@ -112,6 +113,7 @@ class GoalDecompositionRepositoryTest { override suspend fun upsertDependencies(dependencies: List) {} override suspend fun deleteDependency(dependency: TaskDependencyEntity) {} override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) + override fun getDependsOnIds(taskId: String): List = emptyList() override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) override suspend fun deleteAllDependenciesForTask(taskId: String) {} override suspend fun replaceTasksForGoal(goalId: String, tasks: List, dependencies: List) {} @@ -176,6 +178,7 @@ class GoalDecompositionRepositoryTest { override suspend fun createGoal(request: com.awan.app.core.network.dto.goal.CreateGoalRequest): Result = error("Not implemented") override suspend fun getInboxGoal(): Result = error("Not implemented") override suspend fun getGoal(goalId: String): Result = error("Not implemented") + override suspend fun updateGoal(goalId: String, request: com.awan.app.core.network.dto.goal.UpdateGoalRequest): Result = error("Not implemented") override suspend fun deleteGoal(goalId: String): Result = error("Not implemented") override suspend fun continueDecomposition(request: GoalDecomposeRequest): Result = error("Not implemented") override suspend fun confirmDecomposition(sessionId: String): Result = error("Not implemented") diff --git a/core/data/src/test/java/com/awan/app/core/data/goal/GoalRepositoryTest.kt b/core/data/src/test/java/com/awan/app/core/data/goal/GoalRepositoryTest.kt index 2de3ff7d..0c51208b 100644 --- a/core/data/src/test/java/com/awan/app/core/data/goal/GoalRepositoryTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/goal/GoalRepositoryTest.kt @@ -41,6 +41,7 @@ class GoalRepositoryTest { override suspend fun createGoal(request: com.awan.app.core.network.dto.goal.CreateGoalRequest): GoalInfoResponse = error("Not implemented") override suspend fun getInboxGoal(): GoalInfoResponse = error("Not implemented") override suspend fun getGoal(goalId: String, expand: Boolean): GoalInfoResponse = error("Not implemented") + override suspend fun updateGoal(goalId: String, request: com.awan.app.core.network.dto.goal.UpdateGoalRequest): GoalInfoResponse = error("Not implemented") override suspend fun deleteGoal(goalId: String) = error("Not implemented") override suspend fun decomposeGoal( @@ -65,6 +66,7 @@ class GoalRepositoryTest { override suspend fun createGoal(request: com.awan.app.core.network.dto.goal.CreateGoalRequest): Result = error("Not implemented") override suspend fun getInboxGoal(): Result = error("Not implemented") override suspend fun getGoal(goalId: String): Result = error("Not implemented") + override suspend fun updateGoal(goalId: String, request: com.awan.app.core.network.dto.goal.UpdateGoalRequest): Result = error("Not implemented") override suspend fun deleteGoal(goalId: String): Result = error("Not implemented") override suspend fun continueDecomposition(request: com.awan.app.core.network.dto.GoalDecomposeRequest): Result = error("Not implemented") override suspend fun confirmDecomposition(sessionId: String): Result = error("Not implemented") @@ -103,6 +105,7 @@ class GoalRepositoryTest { override suspend fun upsertDependencies(dependencies: List) {} override suspend fun deleteDependency(dependency: TaskDependencyEntity) {} override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) + override fun getDependsOnIds(taskId: String): List = emptyList() override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) override suspend fun deleteAllDependenciesForTask(taskId: String) {} override suspend fun replaceTasksForGoal(goalId: String, tasks: List, dependencies: List) {} diff --git a/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt b/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt index 26084731..6c30e1e9 100644 --- a/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt @@ -146,6 +146,7 @@ private class FakeTaskDao : TaskDao { override suspend fun upsertDependencies(dependencies: List) {} override suspend fun deleteDependency(dependency: TaskDependencyEntity) {} override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) + override fun getDependsOnIds(taskId: String): List = emptyList() override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) override suspend fun deleteTasksByGoal(goalId: String) {} override suspend fun nullifyOrphanedGoalReferences() {} diff --git a/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt b/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt index 91eaa511..2918f42c 100644 --- a/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt @@ -108,6 +108,7 @@ private class FakeGoalRemoteDataSource( override suspend fun createGoal(request: com.awan.app.core.network.dto.goal.CreateGoalRequest) = error("not used") override suspend fun getInboxGoal() = error("not used") override suspend fun getGoal(goalId: String) = error("not used") + override suspend fun updateGoal(goalId: String, request: com.awan.app.core.network.dto.goal.UpdateGoalRequest) = error("not used") override suspend fun deleteGoal(goalId: String) = error("not used") override suspend fun continueDecomposition(request: GoalDecomposeRequest) = error("not used") override suspend fun confirmDecomposition(sessionId: String) = error("not used") @@ -193,6 +194,7 @@ private class FakeTaskDao : TaskDao { override suspend fun upsertDependencies(dependencies: List) {} override suspend fun deleteDependency(dependency: TaskDependencyEntity) {} override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) + override fun getDependsOnIds(taskId: String): List = emptyList() override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) override suspend fun deleteAllDependenciesForTask(taskId: String) {} override suspend fun replaceTasksForGoal(goalId: String, tasks: List, dependencies: List) {} diff --git a/core/data/src/test/java/com/awan/app/core/data/task/AiTaskRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/task/AiTaskRepositoryImplTest.kt index 857ace32..6e7558bd 100644 --- a/core/data/src/test/java/com/awan/app/core/data/task/AiTaskRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/task/AiTaskRepositoryImplTest.kt @@ -102,6 +102,7 @@ class AiTaskRepositoryImplTest { override suspend fun upsertDependencies(dependencies: List) {} override suspend fun deleteDependency(dependency: TaskDependencyEntity) {} override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) + override fun getDependsOnIds(taskId: String): List = emptyList() override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) override suspend fun deleteAllDependenciesForTask(taskId: String) {} override suspend fun replaceTasksForGoal(goalId: String, tasks: List, dependencies: List) {} diff --git a/core/data/src/test/java/com/awan/app/core/data/task/TaskRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/task/TaskRepositoryImplTest.kt index 347ba5fa..f9e1f80a 100644 --- a/core/data/src/test/java/com/awan/app/core/data/task/TaskRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/task/TaskRepositoryImplTest.kt @@ -57,6 +57,7 @@ private class FakeTaskDao : TaskDao { override suspend fun upsertDependencies(dependencies: List) {} override suspend fun deleteDependency(dependency: TaskDependencyEntity) {} override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) + override fun getDependsOnIds(taskId: String): List = emptyList() override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) override suspend fun deleteAllDependenciesForTask(taskId: String) {} override suspend fun replaceTasksForGoal( diff --git a/feature/goals/impl/src/test/java/com/awan/feature/goals/impl/presentation/GoalsViewModelTest.kt b/feature/goals/impl/src/test/java/com/awan/feature/goals/impl/presentation/GoalsViewModelTest.kt index cab71ee4..6375e74b 100644 --- a/feature/goals/impl/src/test/java/com/awan/feature/goals/impl/presentation/GoalsViewModelTest.kt +++ b/feature/goals/impl/src/test/java/com/awan/feature/goals/impl/presentation/GoalsViewModelTest.kt @@ -4,6 +4,7 @@ import com.awan.app.core.common.error.AppError import com.awan.app.core.common.result.Result import com.awan.app.core.domain.goal.repository.GoalRepository import com.awan.app.core.domain.goal.usecase.GetGoalsUseCase +import com.awan.app.core.domain.goal.usecase.ObserveGoalsUseCase import com.awan.app.core.model.Goal import com.awan.app.core.model.GoalDecompositionReply import com.awan.app.core.model.GoalStatus @@ -35,8 +36,16 @@ class GoalsViewModelTest { } private class FakeGoalRepository : GoalRepository { + val goalsFlow = kotlinx.coroutines.flow.MutableStateFlow>(emptyList()) var result: Result> = Result.Success(emptyList()) - + set(value) { + field = value + if (value is Result.Success) { + goalsFlow.value = value.data + } + } + + override fun observeGoals(): kotlinx.coroutines.flow.Flow> = goalsFlow override suspend fun getGoals(): Result> = result override suspend fun continueDecomposition( @@ -50,6 +59,13 @@ class GoalsViewModelTest { override suspend fun createGoal(title: String, description: String?, targetDate: String?): Result = error("Not implemented") override suspend fun getInboxGoal(): Result = error("Not implemented") override suspend fun getGoal(goalId: String): Result = error("Not implemented") + override suspend fun updateGoal( + goalId: String, + title: String?, + description: String?, + status: String?, + targetDate: String?, + ): Result = error("Not implemented") override suspend fun deleteGoal(goalId: String): Result = error("Not implemented") override suspend fun getDecompositionTranscript(sessionId: String): Result = error("Not implemented") override suspend fun cancelDecomposition(sessionId: String): Result = error("Not implemented") @@ -68,7 +84,7 @@ class GoalsViewModelTest { ) ) } - val viewModel = GoalsViewModel(GetGoalsUseCase(repo)) + val viewModel = GoalsViewModel(GetGoalsUseCase(repo), ObserveGoalsUseCase(repo)) val state = viewModel.state.value assertFalse(state.isLoading) @@ -84,7 +100,7 @@ class GoalsViewModelTest { val repo = FakeGoalRepository().apply { result = Result.Error(AppError.Network) } - val viewModel = GoalsViewModel(GetGoalsUseCase(repo)) + val viewModel = GoalsViewModel(GetGoalsUseCase(repo), ObserveGoalsUseCase(repo)) val errorState = viewModel.state.value assertFalse(errorState.isLoading) @@ -105,7 +121,7 @@ class GoalsViewModelTest { @Test fun `tab selection changes only the selected tab`() { val repo = FakeGoalRepository() - val viewModel = GoalsViewModel(GetGoalsUseCase(repo)) + val viewModel = GoalsViewModel(GetGoalsUseCase(repo), ObserveGoalsUseCase(repo)) assertEquals(GoalsTab.Active, viewModel.state.value.tab) From 7a606db02c2bfb4126b914d05d07b7c94c6d3ffb Mon Sep 17 00:00:00 2001 From: Abdallah-Elsobky Date: Tue, 11 Aug 2026 15:43:10 +0300 Subject: [PATCH 5/5] fix(goals): support SavedStateHandle fallback goalId, Retry goal loading, and clear search focus --- .../goals/impl/presentation/GoalDetailsViewModel.kt | 8 +++++--- .../feature/goals/impl/ui/components/GoalsSearchBar.kt | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt index 202ba62c..3f1def2d 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt @@ -29,7 +29,8 @@ class GoalDetailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val goalId: String = checkNotNull(savedStateHandle["id"]) + private val goalId: String? = savedStateHandle.get("goalId") ?: savedStateHandle.get("id") + private var currentGoalId: String? = goalId private val _state = MutableStateFlow(GoalDetailsState()) val state: StateFlow = _state.asStateFlow() @@ -38,10 +39,11 @@ class GoalDetailsViewModel @Inject constructor( val events = _events.receiveAsFlow() init { - loadGoal(goalId) + currentGoalId?.let { loadGoal(it) } } fun loadGoal(id: String) { + currentGoalId = id viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } when (val result = getGoalUseCase(id)) { @@ -66,7 +68,7 @@ class GoalDetailsViewModel @Inject constructor( fun onAction(action: GoalDetailsAction) { when (action) { GoalDetailsAction.Retry -> { - loadGoal(goalId) + currentGoalId?.let { loadGoal(it) } } GoalDetailsAction.Back -> { diff --git a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt index 44924717..a5d7b028 100644 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt @@ -43,6 +43,8 @@ internal fun GoalsSearchBar( val colors = AwanTheme.colors val shape = RoundedCornerShape(16.dp) + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + Row( modifier = modifier .fillMaxWidth() @@ -92,8 +94,7 @@ internal fun GoalsSearchBar( keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions( onSearch = { - // The parent usually handles onQueryChange, but if there's a specific search action - // we could trigger it here. For now, just clearing focus is standard. + focusManager.clearFocus() } ) )