diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt index 3c0de9e2..6faaf5c0 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..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 @@ -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 @@ -15,7 +18,21 @@ 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 /** @@ -29,55 +46,109 @@ 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> { - val entities = goalDao.getAllGoals() - return Result.Success(entities.map { it.toModel() }) + 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) } + } + val models = goalDao.getAllGoals().map { it.toModelWithTasks() } + 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( - CreateGoalRequest( - title = title, - description = description, - targetDate = targetDate, - ), + remoteDataSource.createGoal( + CreateGoalRequest(title = title, description = description, targetDate = targetDate), ).map { dto -> - val entity = dto.toEntity() - goalDao.upsertGoal(entity) - entity.toModel() + syncGoal(dto) + dto.toEntity().toModelWithTasks() } } - override suspend fun getInboxGoal(): Result { - val cached = goalDao.getAllGoals().find { it.isInbox } - if (cached != null) { - return Result.Success(cached.toModel()) + override suspend fun getInboxGoal(): Result = withContext(ioDispatcher) { + if (connectivityMonitor.isCurrentlyOnline()) { + remoteDataSource.getInboxGoal().suspendOnSuccess { syncGoal(it) } } - return 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 { - val entity = goalDao.getGoal(goalId) - if (entity != null) return Result.Success(entity.toModel()) - return Result.Error(AppError.NotFound) + override suspend fun getGoal(goalId: String): Result = withContext(ioDispatcher) { + if (connectivityMonitor.isCurrentlyOnline()) { + remoteDataSource.getGoal(goalId).suspendOnSuccess { syncGoal(it) } + } + goalDao.getGoal(goalId)?.let { + Result.Success(it.toModelWithTasks()) + } ?: Result.Error(AppError.NotFound) } - override suspend fun deleteGoal(goalId: String): Result { + override suspend fun updateGoal( + goalId: String, + title: String?, + description: String?, + status: String?, + targetDate: String?, + ): Result = withContext(ioDispatcher) { if (!connectivityMonitor.isCurrentlyOnline()) { - return Result.Error(AppError.Network) + 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"))) } - return remoteDataSource.deleteGoal(goalId).map { + + 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) + } + + // 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"))) + } + + remoteDataSource.deleteGoal(goalId).map { goalDao.deleteGoal(goalId) + taskDao.deleteTasksByGoal(goalId) } } @@ -93,14 +164,38 @@ 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) + } + remoteDataSource.confirmDecomposition(sessionId).map { dto -> + syncGoal(dto) + dto.toEntity().toModelWithTasks() + } + } + + private suspend fun GoalEntity.toModelWithTasks(): Goal { + val tasks = taskDao.getTasksByGoal(id).map { taskEntity -> + taskEntity.toTaskModel(dependsOnTaskIds = taskDao.getDependsOnIds(taskEntity.id)) } - return remoteDataSource.confirmDecomposition(sessionId).map { dto -> - val entity = dto.toEntity() - goalDao.upsertGoal(entity) - entity.toModel() + 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)) + 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 -> + 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 39d76d62..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,6 +69,21 @@ internal fun TaskInfoResponse.toTaskModel(): Task = Task( category = category?.toModel(), ) +internal fun com.awan.app.core.database.model.TaskEntity.toTaskModel( + dependsOnTaskIds: List = emptyList() +): Task = Task( + id = id, + title = title, + description = description, + estimatedDurationMinutes = estimatedDuration, + status = status.toTaskStatus(), + mandatory = mandatory, + estimatedPoints = estimatedPoints, + allowTaskSplitting = allowTaskSplitting, + goalId = goalId, + dependsOnTaskIds = dependsOnTaskIds, +) + internal fun TaskProposalResponse.toModel(): TaskProposals = TaskProposals( sourceSummary = sourceSummary, tasks = tasks.map { it.toModel() }, @@ -195,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/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..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 @@ -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: @@ -60,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( @@ -93,12 +97,30 @@ 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 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) {} + override suspend fun deleteTasksByGoal(goalId: String) {} + override suspend fun nullifyOrphanedGoalReferences() {} + } + // --- C. Remote data source / repository behavior --- @Test @@ -156,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") @@ -174,7 +197,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 +211,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..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 @@ -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 @@ -38,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( @@ -62,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") @@ -84,12 +89,30 @@ 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 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) {} + 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 +169,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 +195,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..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 @@ -140,17 +140,21 @@ 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) {} 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() {} + 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..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") @@ -185,9 +186,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) {} @@ -195,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) {} @@ -237,9 +237,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..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 @@ -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) {} @@ -104,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 9ac96092..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 @@ -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 } @@ -59,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( @@ -249,9 +248,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/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..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 @@ -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 @@ -58,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/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/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/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/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/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..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 @@ -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,48 @@ 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, + events = viewModel.events, + onAction = viewModel::onAction, + onNavigateBack = onBack + ) } } @@ -54,11 +91,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 +129,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 +145,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 - .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, - ), + 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), + ) + + 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/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/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 new file mode 100644 index 00000000..3f1def2d --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalDetailsViewModel.kt @@ -0,0 +1,131 @@ +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 +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 + +@HiltViewModel +class GoalDetailsViewModel @Inject constructor( + private val getGoalUseCase: GetGoalUseCase, + private val updateGoalUseCase: UpdateGoalUseCase, + private val deleteGoalUseCase: DeleteGoalUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + 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() + + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + + init { + 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)) { + is Result.Success -> { + _state.update { it.copy(isLoading = false, goal = result.data) } + } + + is Result.Error -> { + _state.update { + it.copy( + isLoading = false, + error = result.error.toUiText() + ) + } + } + + Result.Loading -> {} + } + } + } + + fun onAction(action: GoalDetailsAction) { + when (action) { + GoalDetailsAction.Retry -> { + currentGoalId?.let { loadGoal(it) } + } + + GoalDetailsAction.Back -> { + viewModelScope.launch { + _events.send(GoalDetailsEvent.NavigateBack) + } + } + + GoalDetailsAction.DeleteClicked -> deleteGoal() + GoalDetailsAction.EditClicked -> _state.update { it.copy(showEditSheet = true) } + GoalDetailsAction.EditDismissed -> _state.update { it.copy(showEditSheet = false) } + is GoalDetailsAction.GoalUpdated -> updateGoal(action) + } + } + + 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 updateGoal(action: GoalDetailsAction.GoalUpdated) { + val goalId = _state.value.goal?.id ?: return + viewModelScope.launch { + _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 -> { + _state.update { + it.copy( + isUpdating = false, + showEditSheet = false, + goal = result.data + ) + } + } + is Result.Error -> { + _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/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 b48f7f6d..00000000 --- a/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/presentation/GoalsMvi.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.awan.feature.goals.impl.presentation - -import com.awan.app.core.model.Goal - -enum class GoalsTab { - Active, - Completed, -} - -data class GoalsState( - val isLoading: Boolean = true, - val isError: Boolean = false, - val tab: GoalsTab = GoalsTab.Active, - val activeGoals: List = emptyList(), - val completedGoals: List = emptyList(), -) - -sealed interface GoalsAction { - data class TabSelected(val tab: GoalsTab) : GoalsAction - data object RetryClicked : GoalsAction -} 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 f1f4799c..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,10 +4,13 @@ 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 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 @@ -15,44 +18,59 @@ import javax.inject.Inject @HiltViewModel class GoalsViewModel @Inject constructor( private val getGoalsUseCase: GetGoalsUseCase, + private val observeGoalsUseCase: ObserveGoalsUseCase, ) : ViewModel() { private val _state = MutableStateFlow(GoalsState()) val state: StateFlow = _state.asStateFlow() + private val _events = Channel(Channel.BUFFERED) + 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 -> refreshGoals() + is GoalsAction.GoalClicked -> { + viewModelScope.launch { + _events.send(GoalsEvent.NavigateToGoalDetails(action.goalId)) + } + } + is GoalsAction.TabSelected -> _state.update { it.copy(tab = action.tab) } - GoalsAction.RetryClicked -> loadGoals() } } - 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) } 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 }, ) } } 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/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 68% 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 6dc75766..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,29 +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(), -) - -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 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/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/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..9bd5e595 --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/GoalDetailsScreen.kt @@ -0,0 +1,519 @@ +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.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 +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.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.Lucide +import com.composables.icons.lucide.Pencil +import com.composables.icons.lucide.Trash2 +import kotlinx.coroutines.flow.Flow + +@Composable +fun GoalDetailsScreen( + state: GoalDetailsState, + events: Flow, + onAction: (GoalDetailsAction) -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier +) { + val colors = AwanTheme.colors + + ObserveAsEvents(events) { event -> + when (event) { + GoalDetailsEvent.NavigateBack -> onNavigateBack() + } + } + + Scaffold( + topBar = { + GoalDetailsTopBar( + title = state.goal?.title ?: "", + onBack = { onAction(GoalDetailsAction.Back) }, + onEditClick = { onAction(GoalDetailsAction.EditClicked) } + ) + }, + 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, + isDeleting = state.isDeleting, + onDeleteClick = { onAction(GoalDetailsAction.DeleteClicked) } + ) + } + state.error != null -> { + AwanText( + 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)) + } + ) + } + } +} + +@Composable +private fun GoalDetailsTopBar( + title: String, + onBack: () -> Unit, + onEditClick: () -> Unit +) { + val colors = AwanTheme.colors + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.background) + .statusBarsPadding() + .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 = 2, + overflow = TextOverflow.Ellipsis + ) + } + + AwanIconButton( + onClick = onEditClick, + contentDescription = "Edit Goal", + icon = { + Icon( + imageVector = Lucide.Pencil, + contentDescription = null, + tint = colors.sky, + modifier = Modifier.size(20.dp) + ) + } + ) + } +} + +@Composable +private fun GoalDetailsContent( + goal: Goal, + isDeleting: Boolean, + onDeleteClick: () -> Unit +) { + val accentColor = goalAccentColor(goal.id.hashCode()) + + 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(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)) } + } +} + +@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 = goal.status.name, + 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 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( + 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) + .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 + .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 + ) + ) + } + } + + // 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..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,45 +1,52 @@ 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 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.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.graphics.Brush -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +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 -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, @@ -48,26 +55,24 @@ fun GoalsScreen( ) { val colors = AwanTheme.colors + var showAllActive by remember { mutableStateOf(false) } + var showAllAchieved by remember { mutableStateOf(false) } + Box( modifier = modifier.fillMaxSize(), ) { Column( modifier = Modifier.fillMaxSize(), ) { - // ── Mascot header ───────────────────────────────────────────────── - GoalsMascotHeader() - - Spacer(modifier = Modifier.height(16.dp)) - - // ── Tab row ─────────────────────────────────────────────────────── - GoalsTabRow( - selectedTab = state.tab, - activeCount = state.activeGoals.size, - completedCount = state.completedGoals.size, - onTabSelected = { onAction(GoalsAction.TabSelected(it)) }, + // ── 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)) // ── Content area ────────────────────────────────────────────────── when { @@ -88,34 +93,126 @@ fun GoalsScreen( } else -> { - val goals = when (state.tab) { - GoalsTab.Active -> state.activeGoals - GoalsTab.Completed -> state.completedGoals - } - val isCompletedTab = state.tab == GoalsTab.Completed + val filteredGoals = state.filteredGoals + val activeGoals = filteredGoals.filter { it.status == GoalStatus.ACTIVE } + val achievedGoals = filteredGoals.filter { it.status == GoalStatus.ACHIEVED } - if (goals.isEmpty()) { - GoalsEmptyState(tab = state.tab) + 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(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, - ) - } + 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)) } } } } } } + + // ── Floating Clouds ───────────────────────────────────────────── + com.awan.app.core.designsystem.AwanCloudsHorizon( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) + } +} + +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/GoalCard.kt b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalCard.kt index b2d189c4..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 @@ -1,239 +1,224 @@ 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.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 +226,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/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/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..a5d7b028 --- /dev/null +++ b/feature/goals/impl/src/main/java/com/awan/feature/goals/impl/ui/components/GoalsSearchBar.kt @@ -0,0 +1,122 @@ +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.KeyboardActions +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) + + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + + 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), + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + } + ) + ) + } + } + + 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..173e04a7 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,12 @@ - أهدافي - النشطة - المكتملة - %1$s %2$d + البحث عن المهام أو الجلسات + الأهداف + نشط + مكتمل + %1$d مهام + %1$d مستقل + %1$d تابع %1$d من %2$d مهمة منجزة %1$d%% لا توجد أهداف نشطة بعد @@ -15,6 +18,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 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) 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 = "",