Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/src/main/java/com/awan/app/AwanApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ internal fun GoalInfoResponse.toModel(): Goal {
emoji = extractedEmoji,
status = status.toModel(),
tasks = tasks.map { it.toTaskModel() },
targetDate = targetDate,
)
}

Expand All @@ -68,6 +69,7 @@ internal fun GoalEntity.toModel(): Goal {
emoji = extractedEmoji,
status = goalStatus,
tasks = emptyList(), // tasks are stored separately in TaskEntity
targetDate = targetDate,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

/**
Expand All @@ -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<List<Goal>> {
val entities = goalDao.getAllGoals()
return Result.Success(entities.map { it.toModel() })
override fun observeGoals(): Flow<List<Goal>> {
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<List<Goal>> = 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<Goal> {
): Result<Goal> = 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<Goal> {
val cached = goalDao.getAllGoals().find { it.isInbox }
if (cached != null) {
return Result.Success(cached.toModel())
override suspend fun getInboxGoal(): Result<Goal> = 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<Goal> {
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<Goal> = 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<Unit> {
override suspend fun updateGoal(
goalId: String,
title: String?,
description: String?,
status: String?,
targetDate: String?,
): Result<Goal> = 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<Unit> = 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)
}
}

Expand All @@ -93,14 +164,38 @@ class GoalRepositoryImpl @Inject constructor(
).map { it.toDecompositionReply() }
}

override suspend fun confirmDecomposition(sessionId: String): Result<Goal> {
override suspend fun confirmDecomposition(sessionId: String): Result<Goal> = 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<GoalInfoResponse>) {
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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@ interface GoalRemoteDataSource {
suspend fun createGoal(request: CreateGoalRequest): Result<GoalInfoResponse>
suspend fun getInboxGoal(): Result<GoalInfoResponse>
suspend fun getGoal(goalId: String): Result<GoalInfoResponse>
suspend fun updateGoal(goalId: String, request: UpdateGoalRequest): Result<GoalInfoResponse>
suspend fun deleteGoal(goalId: String): Result<Unit>
suspend fun continueDecomposition(request: GoalDecomposeRequest): Result<GoalDecomposeResponse>
suspend fun confirmDecomposition(sessionId: String): Result<GoalInfoResponse>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,6 +45,14 @@ class GoalRemoteDataSourceImpl @Inject constructor(
goalApiService.getGoal(goalId)
}

override suspend fun updateGoal(
goalId: String,
request: UpdateGoalRequest,
): Result<GoalInfoResponse> =
safeApiCall(dispatcher = ioDispatcher, json = json) {
goalApiService.updateGoal(goalId, request)
}

override suspend fun deleteGoal(goalId: String): Result<Unit> =
safeApiCall(dispatcher = ioDispatcher, json = json) {
goalApiService.deleteGoal(goalId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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() },
Expand Down Expand Up @@ -195,6 +210,16 @@ internal fun TaskInfoResponse.toEntity(
expiryTime = expiryTime,
)

internal fun TaskInfoResponse.toDependencyEntities(): List<com.awan.app.core.database.model.TaskDependencyEntity> {
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,
Expand Down
Loading
Loading