From 2de916cfd9eb0d7c732f4395197879ee845b75cd Mon Sep 17 00:00:00 2001 From: gleb Date: Thu, 25 Dec 2025 22:49:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20=D1=81=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=B4=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/local/entity/RewardEntity.kt | 7 +- .../data/local/mapper/RewardMapper.kt | 18 +- .../novayaplaneta/data/remote/BackendApi.kt | 31 ++ .../data/remote/dto/ClaimRewardResponse.kt | 17 ++ .../data/remote/dto/CreateRewardRequest.kt | 12 + .../data/remote/dto/RewardDto.kt | 17 ++ .../data/remote/mapper/RewardMapper.kt | 80 +++++ .../data/repository/RewardRepositoryImpl.kt | 152 +++++++++- .../com/novayaplaneta/domain/model/Reward.kt | 7 +- .../domain/repository/RewardRepository.kt | 9 +- .../domain/usecase/ClaimRewardUseCase.kt | 14 + .../domain/usecase/CreateRewardUseCase.kt | 14 + .../domain/usecase/DeleteRewardUseCase.kt | 13 + .../domain/usecase/GetRewardByIdUseCase.kt | 14 + .../domain/usecase/GetRewardsUseCase.kt | 23 ++ .../ui/screens/rewards/RewardsScreen.kt | 169 +++++++++-- .../ui/screens/rewards/RewardsViewModel.kt | 287 +++++++++++------- 17 files changed, 715 insertions(+), 169 deletions(-) create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/ClaimRewardResponse.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/CreateRewardRequest.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/dto/RewardDto.kt create mode 100644 app/src/main/java/com/novayaplaneta/data/remote/mapper/RewardMapper.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/ClaimRewardUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/CreateRewardUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/DeleteRewardUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/GetRewardByIdUseCase.kt create mode 100644 app/src/main/java/com/novayaplaneta/domain/usecase/GetRewardsUseCase.kt diff --git a/app/src/main/java/com/novayaplaneta/data/local/entity/RewardEntity.kt b/app/src/main/java/com/novayaplaneta/data/local/entity/RewardEntity.kt index 3ab982b..b18ecd7 100644 --- a/app/src/main/java/com/novayaplaneta/data/local/entity/RewardEntity.kt +++ b/app/src/main/java/com/novayaplaneta/data/local/entity/RewardEntity.kt @@ -10,8 +10,11 @@ data class RewardEntity( val title: String, val description: String?, val imageUrl: String?, - val points: Int, + val pointsRequired: Int, + val isClaimed: Boolean = false, val earnedAt: Long?, // timestamp - val userId: String + val userId: String, + val createdAt: Long? = null, // timestamp + val updatedAt: Long? = null // timestamp ) diff --git a/app/src/main/java/com/novayaplaneta/data/local/mapper/RewardMapper.kt b/app/src/main/java/com/novayaplaneta/data/local/mapper/RewardMapper.kt index ffcedf8..0c67334 100644 --- a/app/src/main/java/com/novayaplaneta/data/local/mapper/RewardMapper.kt +++ b/app/src/main/java/com/novayaplaneta/data/local/mapper/RewardMapper.kt @@ -12,11 +12,18 @@ fun RewardEntity.toDomain(): Reward { title = title, description = description, imageUrl = imageUrl, - points = points, + pointsRequired = pointsRequired, + isClaimed = isClaimed, earnedAt = earnedAt?.let { LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) }, - userId = userId + userId = userId, + createdAt = createdAt?.let { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + }, + updatedAt = updatedAt?.let { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + } ) } @@ -26,9 +33,12 @@ fun Reward.toEntity(): RewardEntity { title = title, description = description, imageUrl = imageUrl, - points = points, + pointsRequired = pointsRequired, + isClaimed = isClaimed, earnedAt = earnedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(), - userId = userId + userId = userId, + createdAt = createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli(), + updatedAt = updatedAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ) } diff --git a/app/src/main/java/com/novayaplaneta/data/remote/BackendApi.kt b/app/src/main/java/com/novayaplaneta/data/remote/BackendApi.kt index 620654b..605fe3c 100644 --- a/app/src/main/java/com/novayaplaneta/data/remote/BackendApi.kt +++ b/app/src/main/java/com/novayaplaneta/data/remote/BackendApi.kt @@ -2,11 +2,14 @@ package com.novayaplaneta.data.remote import com.novayaplaneta.data.remote.dto.ChatRequest import com.novayaplaneta.data.remote.dto.ChatResponse +import com.novayaplaneta.data.remote.dto.ClaimRewardResponse import com.novayaplaneta.data.remote.dto.CompleteTaskResponse +import com.novayaplaneta.data.remote.dto.CreateRewardRequest import com.novayaplaneta.data.remote.dto.CreateScheduleRequest import com.novayaplaneta.data.remote.dto.CreateTaskRequest import com.novayaplaneta.data.remote.dto.LoginRequest import com.novayaplaneta.data.remote.dto.LoginResponse +import com.novayaplaneta.data.remote.dto.RewardDto import com.novayaplaneta.data.remote.dto.ScheduleDto import com.novayaplaneta.data.remote.dto.TaskDto import retrofit2.Response @@ -79,5 +82,33 @@ interface BackendApi { @Path("task_id") taskId: String, @Query("completed") completed: Boolean ): Response + + // Rewards endpoints + @GET("api/v1/rewards") + suspend fun getRewards( + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 100, + @Query("is_claimed") isClaimed: Boolean? = null + ): Response> + + @GET("api/v1/rewards/{reward_id}") + suspend fun getRewardById( + @Path("reward_id") rewardId: String + ): Response + + @POST("api/v1/rewards") + suspend fun createReward( + @Body request: CreateRewardRequest + ): Response + + @DELETE("api/v1/rewards/{reward_id}") + suspend fun deleteReward( + @Path("reward_id") rewardId: String + ): Response + + @POST("api/v1/rewards/{reward_id}/claim") + suspend fun claimReward( + @Path("reward_id") rewardId: String + ): Response } diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/ClaimRewardResponse.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/ClaimRewardResponse.kt new file mode 100644 index 0000000..5b42c87 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/ClaimRewardResponse.kt @@ -0,0 +1,17 @@ +package com.novayaplaneta.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ClaimRewardResponse( + val id: String, + val title: String, + val description: String?, + val image_url: String?, + val points_required: Int, + val user_id: String, + val is_claimed: Boolean, + val created_at: String, + val updated_at: String +) + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateRewardRequest.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateRewardRequest.kt new file mode 100644 index 0000000..5236348 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/CreateRewardRequest.kt @@ -0,0 +1,12 @@ +package com.novayaplaneta.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateRewardRequest( + val title: String, + val description: String? = null, + val image_url: String? = null, + val points_required: Int +) + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/dto/RewardDto.kt b/app/src/main/java/com/novayaplaneta/data/remote/dto/RewardDto.kt new file mode 100644 index 0000000..5f1e06c --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/dto/RewardDto.kt @@ -0,0 +1,17 @@ +package com.novayaplaneta.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RewardDto( + val id: String, + val title: String, + val description: String?, + val image_url: String?, + val points_required: Int, + val user_id: String, + val is_claimed: Boolean, + val created_at: String, + val updated_at: String +) + diff --git a/app/src/main/java/com/novayaplaneta/data/remote/mapper/RewardMapper.kt b/app/src/main/java/com/novayaplaneta/data/remote/mapper/RewardMapper.kt new file mode 100644 index 0000000..0933922 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/data/remote/mapper/RewardMapper.kt @@ -0,0 +1,80 @@ +package com.novayaplaneta.data.remote.mapper + +import com.novayaplaneta.data.remote.dto.ClaimRewardResponse +import com.novayaplaneta.data.remote.dto.CreateRewardRequest +import com.novayaplaneta.data.remote.dto.RewardDto +import com.novayaplaneta.domain.model.Reward +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +fun RewardDto.toDomain(): Reward { + val dateFormatter = DateTimeFormatter.ISO_DATE_TIME + + val createdAtDateTime = try { + LocalDateTime.parse(created_at, dateFormatter) + } catch (e: Exception) { + null + } + + val updatedAtDateTime = try { + LocalDateTime.parse(updated_at, dateFormatter) + } catch (e: Exception) { + null + } + + val earnedAtDateTime = if (is_claimed) updatedAtDateTime else null + + return Reward( + id = id, + title = title, + description = description, + imageUrl = image_url, + pointsRequired = points_required, + isClaimed = is_claimed, + earnedAt = earnedAtDateTime, + userId = user_id, + createdAt = createdAtDateTime, + updatedAt = updatedAtDateTime + ) +} + +fun ClaimRewardResponse.toDomain(): Reward { + val dateFormatter = DateTimeFormatter.ISO_DATE_TIME + + val createdAtDateTime = try { + LocalDateTime.parse(created_at, dateFormatter) + } catch (e: Exception) { + null + } + + val updatedAtDateTime = try { + LocalDateTime.parse(updated_at, dateFormatter) + } catch (e: Exception) { + null + } + + val earnedAtDateTime = if (is_claimed) updatedAtDateTime else null + + return Reward( + id = id, + title = title, + description = description, + imageUrl = image_url, + pointsRequired = points_required, + isClaimed = is_claimed, + earnedAt = earnedAtDateTime, + userId = user_id, + createdAt = createdAtDateTime, + updatedAt = updatedAtDateTime + ) +} + +fun Reward.toCreateRequest(): CreateRewardRequest { + return CreateRewardRequest( + title = title, + description = description, + image_url = imageUrl, + points_required = pointsRequired + ) +} + diff --git a/app/src/main/java/com/novayaplaneta/data/repository/RewardRepositoryImpl.kt b/app/src/main/java/com/novayaplaneta/data/repository/RewardRepositoryImpl.kt index ecdc6a2..0ce9e23 100644 --- a/app/src/main/java/com/novayaplaneta/data/repository/RewardRepositoryImpl.kt +++ b/app/src/main/java/com/novayaplaneta/data/repository/RewardRepositoryImpl.kt @@ -3,43 +3,165 @@ package com.novayaplaneta.data.repository import com.novayaplaneta.data.local.dao.RewardDao import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toEntity +import com.novayaplaneta.data.remote.BackendApi +import com.novayaplaneta.data.remote.dto.CreateRewardRequest +import com.novayaplaneta.data.remote.mapper.toCreateRequest +import com.novayaplaneta.data.remote.mapper.toDomain import com.novayaplaneta.domain.model.Reward import com.novayaplaneta.domain.repository.RewardRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map -import java.time.LocalDateTime import javax.inject.Inject class RewardRepositoryImpl @Inject constructor( - private val rewardDao: RewardDao + private val rewardDao: RewardDao, + private val backendApi: BackendApi ) : RewardRepository { - override fun getRewards(userId: String): Flow> { - return rewardDao.getRewardsByUserId(userId).map { rewards -> - rewards.map { it.toDomain() } + // Кэш наград + private val _rewardsCache = MutableStateFlow>(emptyList()) + + override suspend fun loadRewards(skip: Int, limit: Int, isClaimed: Boolean?): Result { + return try { + val response = backendApi.getRewards(skip = skip, limit = limit, isClaimed = isClaimed) + if (response.isSuccessful && response.body() != null) { + val rewards = response.body()!!.map { it.toDomain() } + + // Обновляем кэш + if (skip == 0) { + _rewardsCache.value = rewards + } else { + val currentRewards = _rewardsCache.value.toMutableList() + currentRewards.addAll(rewards) + _rewardsCache.value = currentRewards + } + + // Сохраняем в локальную БД + rewards.forEach { reward -> + rewardDao.insertReward(reward.toEntity()) + } + + Result.success(Unit) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Result.failure(Exception("Failed to load rewards: $errorBody (code: ${response.code()})")) + } + } catch (e: Exception) { + Result.failure(e) } } - override suspend fun getRewardById(id: String): Reward? { - return rewardDao.getRewardById(id)?.toDomain() + override fun getRewards(userId: String): Flow> { + return _rewardsCache.asStateFlow() } - override suspend fun createReward(reward: Reward) { - rewardDao.insertReward(reward.toEntity()) + override suspend fun getRewardById(id: String): Result { + return try { + val response = backendApi.getRewardById(id) + if (response.isSuccessful && response.body() != null) { + val reward = response.body()!!.toDomain() + + // Обновляем в кэше + val currentRewards = _rewardsCache.value.toMutableList() + val index = currentRewards.indexOfFirst { it.id == id } + if (index >= 0) { + currentRewards[index] = reward + } else { + currentRewards.add(reward) + } + _rewardsCache.value = currentRewards + + // Сохраняем в локальную БД + rewardDao.insertReward(reward.toEntity()) + + Result.success(reward) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Result.failure(Exception("Failed to get reward: $errorBody (code: ${response.code()})")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun createReward(reward: Reward): Result { + return try { + val request = reward.toCreateRequest() + val response = backendApi.createReward(request) + if (response.isSuccessful && response.body() != null) { + val createdReward = response.body()!!.toDomain() + + // Обновляем кэш + val currentRewards = _rewardsCache.value.toMutableList() + currentRewards.add(createdReward) + _rewardsCache.value = currentRewards + + // Сохраняем в локальную БД + rewardDao.insertReward(createdReward.toEntity()) + + Result.success(createdReward) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Result.failure(Exception("Failed to create reward: $errorBody (code: ${response.code()})")) + } + } catch (e: Exception) { + Result.failure(e) + } } override suspend fun updateReward(reward: Reward) { rewardDao.updateReward(reward.toEntity()) } - override suspend fun deleteReward(id: String) { - rewardDao.deleteReward(id) + override suspend fun deleteReward(id: String): Result { + return try { + val response = backendApi.deleteReward(id) + if (response.isSuccessful) { + // Удаляем из кэша + val currentRewards = _rewardsCache.value.toMutableList() + currentRewards.removeAll { it.id == id } + _rewardsCache.value = currentRewards + + // Удаляем из локальной БД + rewardDao.deleteReward(id) + + Result.success(Unit) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Result.failure(Exception("Failed to delete reward: $errorBody (code: ${response.code()})")) + } + } catch (e: Exception) { + Result.failure(e) + } } - override suspend fun earnReward(userId: String, rewardId: String) { - val reward = getRewardById(rewardId) - reward?.let { - updateReward(it.copy(earnedAt = LocalDateTime.now())) + override suspend fun claimReward(rewardId: String): Result { + return try { + val response = backendApi.claimReward(rewardId) + if (response.isSuccessful && response.body() != null) { + val claimedReward = response.body()!!.toDomain() + + // Обновляем в кэше + val currentRewards = _rewardsCache.value.toMutableList() + val index = currentRewards.indexOfFirst { it.id == rewardId } + if (index >= 0) { + currentRewards[index] = claimedReward + _rewardsCache.value = currentRewards + } + + // Сохраняем в локальную БД + rewardDao.insertReward(claimedReward.toEntity()) + + Result.success(claimedReward) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Result.failure(Exception("Failed to claim reward: $errorBody (code: ${response.code()})")) + } + } catch (e: Exception) { + Result.failure(e) } } } diff --git a/app/src/main/java/com/novayaplaneta/domain/model/Reward.kt b/app/src/main/java/com/novayaplaneta/domain/model/Reward.kt index f3d6f89..bdaa698 100644 --- a/app/src/main/java/com/novayaplaneta/domain/model/Reward.kt +++ b/app/src/main/java/com/novayaplaneta/domain/model/Reward.kt @@ -7,8 +7,11 @@ data class Reward( val title: String, val description: String?, val imageUrl: String?, - val points: Int, + val pointsRequired: Int, + val isClaimed: Boolean = false, val earnedAt: LocalDateTime? = null, - val userId: String + val userId: String, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null ) diff --git a/app/src/main/java/com/novayaplaneta/domain/repository/RewardRepository.kt b/app/src/main/java/com/novayaplaneta/domain/repository/RewardRepository.kt index 7c6455e..3202267 100644 --- a/app/src/main/java/com/novayaplaneta/domain/repository/RewardRepository.kt +++ b/app/src/main/java/com/novayaplaneta/domain/repository/RewardRepository.kt @@ -4,11 +4,12 @@ import com.novayaplaneta.domain.model.Reward import kotlinx.coroutines.flow.Flow interface RewardRepository { + suspend fun loadRewards(skip: Int = 0, limit: Int = 100, isClaimed: Boolean? = null): Result fun getRewards(userId: String): Flow> - suspend fun getRewardById(id: String): Reward? - suspend fun createReward(reward: Reward) + suspend fun getRewardById(id: String): Result + suspend fun createReward(reward: Reward): Result suspend fun updateReward(reward: Reward) - suspend fun deleteReward(id: String) - suspend fun earnReward(userId: String, rewardId: String) + suspend fun deleteReward(id: String): Result + suspend fun claimReward(rewardId: String): Result } diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/ClaimRewardUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/ClaimRewardUseCase.kt new file mode 100644 index 0000000..a616669 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/ClaimRewardUseCase.kt @@ -0,0 +1,14 @@ +package com.novayaplaneta.domain.usecase + +import com.novayaplaneta.domain.model.Reward +import com.novayaplaneta.domain.repository.RewardRepository +import javax.inject.Inject + +class ClaimRewardUseCase @Inject constructor( + private val repository: RewardRepository +) { + suspend operator fun invoke(rewardId: String): Result { + return repository.claimReward(rewardId) + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/CreateRewardUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/CreateRewardUseCase.kt new file mode 100644 index 0000000..234bd69 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/CreateRewardUseCase.kt @@ -0,0 +1,14 @@ +package com.novayaplaneta.domain.usecase + +import com.novayaplaneta.domain.model.Reward +import com.novayaplaneta.domain.repository.RewardRepository +import javax.inject.Inject + +class CreateRewardUseCase @Inject constructor( + private val repository: RewardRepository +) { + suspend operator fun invoke(reward: Reward): Result { + return repository.createReward(reward) + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/DeleteRewardUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/DeleteRewardUseCase.kt new file mode 100644 index 0000000..149c22e --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/DeleteRewardUseCase.kt @@ -0,0 +1,13 @@ +package com.novayaplaneta.domain.usecase + +import com.novayaplaneta.domain.repository.RewardRepository +import javax.inject.Inject + +class DeleteRewardUseCase @Inject constructor( + private val repository: RewardRepository +) { + suspend operator fun invoke(id: String): Result { + return repository.deleteReward(id) + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/GetRewardByIdUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/GetRewardByIdUseCase.kt new file mode 100644 index 0000000..686b3f5 --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/GetRewardByIdUseCase.kt @@ -0,0 +1,14 @@ +package com.novayaplaneta.domain.usecase + +import com.novayaplaneta.domain.model.Reward +import com.novayaplaneta.domain.repository.RewardRepository +import javax.inject.Inject + +class GetRewardByIdUseCase @Inject constructor( + private val repository: RewardRepository +) { + suspend operator fun invoke(id: String): Result { + return repository.getRewardById(id) + } +} + diff --git a/app/src/main/java/com/novayaplaneta/domain/usecase/GetRewardsUseCase.kt b/app/src/main/java/com/novayaplaneta/domain/usecase/GetRewardsUseCase.kt new file mode 100644 index 0000000..038400b --- /dev/null +++ b/app/src/main/java/com/novayaplaneta/domain/usecase/GetRewardsUseCase.kt @@ -0,0 +1,23 @@ +package com.novayaplaneta.domain.usecase + +import com.novayaplaneta.domain.model.Reward +import com.novayaplaneta.domain.repository.RewardRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetRewardsUseCase @Inject constructor( + private val repository: RewardRepository +) { + operator fun invoke(userId: String): Flow> { + return repository.getRewards(userId) + } + + suspend fun loadRewards(skip: Int = 0, limit: Int = 100, isClaimed: Boolean? = null): Result { + return try { + repository.loadRewards(skip, limit, isClaimed) + } catch (e: Exception) { + Result.failure(e) + } + } +} + diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/rewards/RewardsScreen.kt b/app/src/main/java/com/novayaplaneta/ui/screens/rewards/RewardsScreen.kt index c34661d..5bd9bde 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/rewards/RewardsScreen.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/rewards/RewardsScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CalendarToday @@ -22,7 +23,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -181,8 +181,26 @@ fun RewardsScreen( modifier = Modifier.padding(bottom = 24.dp) ) + // Отображение ошибок + uiState.errorMessage?.let { error -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = error, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + // Список наград в сетке (несколько колонок) - if (uiState.isLoading) { + if (uiState.isLoading && uiState.rewards.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -199,7 +217,10 @@ fun RewardsScreen( items(uiState.rewards) { reward -> RewardCard( reward = reward, - screenHeightDp = screenHeightDp + screenHeightDp = screenHeightDp, + accentGreen = accentGreen, + onClaimClick = { viewModel.claimReward(reward.id) }, + onDeleteClick = { viewModel.deleteReward(reward.id) } ) } @@ -220,11 +241,16 @@ fun RewardsScreen( if (uiState.showAddDialog) { AddRewardDialog( rewardTitle = uiState.newRewardTitle, + rewardDescription = uiState.newRewardDescription, + rewardPoints = uiState.newRewardPoints, onTitleChange = { viewModel.updateNewRewardTitle(it) }, - onAdd = { viewModel.addReward("default") }, + onDescriptionChange = { viewModel.updateNewRewardDescription(it) }, + onPointsChange = { viewModel.updateNewRewardPoints(it) }, + onAdd = { viewModel.createReward() }, onDismiss = { viewModel.hideAddDialog() }, accentGreen = accentGreen, - screenHeightDp = screenHeightDp + screenHeightDp = screenHeightDp, + isLoading = uiState.isLoading ) } } @@ -270,16 +296,20 @@ fun NavItem( @Composable fun RewardCard( reward: com.novayaplaneta.domain.model.Reward, - screenHeightDp: Int + screenHeightDp: Int, + accentGreen: Color, + onClaimClick: () -> Unit = {}, + onDeleteClick: () -> Unit = {} ) { val cardHeight = 180.dp + val isClaimed = reward.isClaimed Box( modifier = Modifier .fillMaxWidth() .height(cardHeight) .clip(RoundedCornerShape(16.dp)) - .background(color = Color.LightGray) + .background(color = if (isClaimed) accentGreen.copy(alpha = 0.3f) else Color.LightGray) ) { Column( modifier = Modifier.fillMaxSize() @@ -289,14 +319,14 @@ fun RewardCard( modifier = Modifier .fillMaxWidth() .weight(0.6f) - .background(color = Color.White.copy(alpha = 0.5f)), + .background(color = if (isClaimed) accentGreen.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.5f)), contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Filled.Star, contentDescription = reward.title, modifier = Modifier.size(60.dp), - tint = AccentGold + tint = if (isClaimed) accentGreen else AccentGold ) } @@ -305,18 +335,48 @@ fun RewardCard( modifier = Modifier .fillMaxWidth() .weight(0.4f) - .background(color = Color.LightGray) + .background(color = if (isClaimed) accentGreen.copy(alpha = 0.3f) else Color.LightGray) .padding(12.dp), contentAlignment = Alignment.Center ) { - val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp - Text( - text = reward.title, - fontSize = textSize, - fontWeight = FontWeight.Bold, - color = Color.Black, - textAlign = TextAlign.Center - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + val textSize = (screenHeightDp * 0.02f).toInt().coerceIn(16, 24).sp + Text( + text = reward.title, + fontSize = textSize, + fontWeight = FontWeight.Bold, + color = Color.Black, + textAlign = TextAlign.Center + ) + Text( + text = "${reward.pointsRequired} очков", + fontSize = textSize * 0.7f, + color = Color.Black.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + if (isClaimed) { + Text( + text = "Получено", + fontSize = textSize * 0.6f, + color = accentGreen, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + } else { + Button( + onClick = onClaimClick, + modifier = Modifier.padding(top = 4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = accentGreen, + contentColor = Color.White + ) + ) { + Text("Получить", fontSize = 10.sp) + } + } + } } } } @@ -356,11 +416,16 @@ fun AddRewardButton( @Composable fun AddRewardDialog( rewardTitle: String, + rewardDescription: String, + rewardPoints: Int, onTitleChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onPointsChange: (Int) -> Unit, onAdd: () -> Unit, onDismiss: () -> Unit, accentGreen: Color, - screenHeightDp: Int + screenHeightDp: Int, + isLoading: Boolean = false ) { Dialog(onDismissRequest = onDismiss) { Card( @@ -393,6 +458,7 @@ fun AddRewardDialog( OutlinedTextField( value = rewardTitle, onValueChange = onTitleChange, + label = { Text("Название") }, placeholder = { Text( text = "Введите название награды", @@ -402,6 +468,7 @@ fun AddRewardDialog( }, modifier = Modifier.fillMaxWidth(), singleLine = true, + enabled = !isLoading, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), colors = OutlinedTextFieldDefaults.colors( focusedTextColor = Color.Black, @@ -414,6 +481,49 @@ fun AddRewardDialog( ) ) + // Поле ввода описания + OutlinedTextField( + value = rewardDescription, + onValueChange = onDescriptionChange, + label = { Text("Описание") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 2, + enabled = !isLoading, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black, + focusedBorderColor = accentGreen, + unfocusedBorderColor = Color.Gray + ), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = inputTextSize + ) + ) + + // Поле ввода очков + OutlinedTextField( + value = rewardPoints.toString(), + onValueChange = { + it.toIntOrNull()?.let { points -> + if (points > 0) onPointsChange(points) + } + }, + label = { Text("Очки") }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black, + focusedBorderColor = accentGreen, + unfocusedBorderColor = Color.Gray + ), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = inputTextSize + ) + ) + // Кнопки внизу Row( modifier = Modifier.fillMaxWidth(), @@ -445,7 +555,7 @@ fun AddRewardDialog( modifier = Modifier .weight(1f) .height(56.dp), - enabled = rewardTitle.isNotBlank(), + enabled = rewardTitle.isNotBlank() && !isLoading, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors( containerColor = accentGreen, @@ -454,12 +564,19 @@ fun AddRewardDialog( disabledContentColor = Color.Gray ) ) { - val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp - Text( - text = "Добавить", - fontSize = buttonTextSize, - fontWeight = FontWeight.Bold - ) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White + ) + } else { + val buttonTextSize = (screenHeightDp * 0.022f).toInt().coerceIn(18, 26).sp + Text( + text = "Добавить", + fontSize = buttonTextSize, + fontWeight = FontWeight.Bold + ) + } } } } diff --git a/app/src/main/java/com/novayaplaneta/ui/screens/rewards/RewardsViewModel.kt b/app/src/main/java/com/novayaplaneta/ui/screens/rewards/RewardsViewModel.kt index b08186c..f90fe1f 100644 --- a/app/src/main/java/com/novayaplaneta/ui/screens/rewards/RewardsViewModel.kt +++ b/app/src/main/java/com/novayaplaneta/ui/screens/rewards/RewardsViewModel.kt @@ -3,166 +3,221 @@ package com.novayaplaneta.ui.screens.rewards import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.novayaplaneta.domain.model.Reward -import com.novayaplaneta.domain.repository.RewardRepository +import com.novayaplaneta.domain.repository.AuthRepository +import com.novayaplaneta.domain.usecase.ClaimRewardUseCase +import com.novayaplaneta.domain.usecase.CreateRewardUseCase +import com.novayaplaneta.domain.usecase.DeleteRewardUseCase +import com.novayaplaneta.domain.usecase.GetRewardsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RewardsViewModel @Inject constructor( - private val rewardRepository: RewardRepository + private val getRewardsUseCase: GetRewardsUseCase, + private val createRewardUseCase: CreateRewardUseCase, + private val deleteRewardUseCase: DeleteRewardUseCase, + private val claimRewardUseCase: ClaimRewardUseCase, + private val authRepository: AuthRepository ) : ViewModel() { private val _uiState = MutableStateFlow(RewardsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var currentUserId: String? = null + init { - loadDefaultRewards() + loadUserId() } - private fun loadDefaultRewards() { - // Создаем список наград по умолчанию - val defaultRewards = listOf( - Reward( - id = "reward_1", - title = "Золотая звезда", - description = "За выполнение 3 задач", - imageUrl = null, - points = 10, - earnedAt = null, - userId = "default" - ), - Reward( - id = "reward_2", - title = "Подарочная коробка", - description = "За выполнение 5 задач", - imageUrl = null, - points = 20, - earnedAt = null, - userId = "default" - ), - Reward( - id = "reward_3", - title = "Игрушка", - description = "За выполнение всех задач дня", - imageUrl = null, - points = 30, - earnedAt = null, - userId = "default" - ), - Reward( - id = "reward_4", - title = "Мультфильм", - description = "За неделю без пропусков", - imageUrl = null, - points = 50, - earnedAt = null, - userId = "default" - ), - Reward( - id = "reward_5", - title = "Поход в парк", - description = "За месяц работы", - imageUrl = null, - points = 100, - earnedAt = null, - userId = "default" - ), - Reward( - id = "reward_6", - title = "Новая книга", - description = "За выполнение 10 задач", - imageUrl = null, - points = 40, - earnedAt = null, - userId = "default" - ), - Reward( - id = "reward_7", - title = "Любимая игра", - description = "За хорошее поведение", - imageUrl = null, - points = 25, - earnedAt = null, - userId = "default" - ), - Reward( - id = "reward_8", - title = "Специальный обед", - description = "За старание в учебе", - imageUrl = null, - points = 35, - earnedAt = null, - userId = "default" - ) - ) - - _uiState.value = _uiState.value.copy(rewards = defaultRewards, isLoading = false) - } - - fun loadRewards(userId: String) { + private fun loadUserId() { viewModelScope.launch { - _uiState.value = _uiState.value.copy(isLoading = true) - rewardRepository.getRewards(userId).collect { rewards -> + try { + val user = authRepository.getCurrentUser().first() + currentUserId = user?.id + if (currentUserId != null) { + loadRewards() + } + } catch (e: Exception) { _uiState.value = _uiState.value.copy( - rewards = if (rewards.isEmpty()) _uiState.value.rewards else rewards, - isLoading = false + errorMessage = e.message ?: "Ошибка загрузки пользователя" ) } } } - fun earnReward(userId: String, rewardId: String) { + fun loadRewards(isClaimed: Boolean? = null) { viewModelScope.launch { - rewardRepository.earnReward(userId, rewardId) + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + try { + val loadResult = getRewardsUseCase.loadRewards(skip = 0, limit = 100, isClaimed = isClaimed) + if (loadResult.isFailure) { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = loadResult.exceptionOrNull()?.message ?: "Ошибка загрузки наград" + ) + return@launch + } + + val userId = currentUserId ?: return@launch + getRewardsUseCase(userId).collect { rewards -> + _uiState.value = _uiState.value.copy( + rewards = rewards, + isLoading = false, + errorMessage = null + ) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = e.message ?: "Ошибка загрузки наград" + ) + } + } + } + + fun claimReward(rewardId: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + // Оптимистичное обновление UI + val currentRewards = _uiState.value.rewards.toMutableList() + val rewardIndex = currentRewards.indexOfFirst { it.id == rewardId } + if (rewardIndex >= 0) { + val oldReward = currentRewards[rewardIndex] + if (oldReward.isClaimed) { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = "Награда уже получена" + ) + return@launch + } + currentRewards[rewardIndex] = oldReward.copy(isClaimed = true) + _uiState.value = _uiState.value.copy(rewards = currentRewards) + } + + val result = claimRewardUseCase(rewardId) + result.onSuccess { + _uiState.value = _uiState.value.copy(isLoading = false) + // Список обновится автоматически через Flow + }.onFailure { exception -> + // Откатываем изменение в случае ошибки + if (rewardIndex >= 0) { + val oldReward = currentRewards[rewardIndex] + currentRewards[rewardIndex] = oldReward.copy(isClaimed = false) + _uiState.value = _uiState.value.copy( + rewards = currentRewards, + isLoading = false, + errorMessage = exception.message ?: "Ошибка получения награды" + ) + } + } } } fun showAddDialog() { - _uiState.value = _uiState.value.copy(showAddDialog = true, newRewardTitle = "") + _uiState.value = _uiState.value.copy( + showAddDialog = true, + newRewardTitle = "", + newRewardDescription = "", + newRewardPoints = 10 + ) } fun hideAddDialog() { - _uiState.value = _uiState.value.copy(showAddDialog = false, newRewardTitle = "") + _uiState.value = _uiState.value.copy( + showAddDialog = false, + newRewardTitle = "", + newRewardDescription = "", + newRewardPoints = 10 + ) } fun updateNewRewardTitle(title: String) { _uiState.value = _uiState.value.copy(newRewardTitle = title) } - fun addReward(userId: String) { - val title = _uiState.value.newRewardTitle.trim() - if (title.isBlank()) return - - val newReward = Reward( - id = "reward_${System.currentTimeMillis()}", - title = title, - description = null, - imageUrl = null, - points = 10, // Базовое количество очков - earnedAt = null, - userId = userId - ) - - // Добавляем новую награду в список - val currentRewards = _uiState.value.rewards.toMutableList() - currentRewards.add(newReward) - - _uiState.value = _uiState.value.copy( - rewards = currentRewards, - showAddDialog = false, - newRewardTitle = "" - ) + fun updateNewRewardDescription(description: String) { + _uiState.value = _uiState.value.copy(newRewardDescription = description) + } + + fun updateNewRewardPoints(points: Int) { + _uiState.value = _uiState.value.copy(newRewardPoints = points) + } + + fun createReward() { + viewModelScope.launch { + val state = _uiState.value + val userId = currentUserId ?: return@launch + + if (state.newRewardTitle.isBlank()) return@launch + + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + val newReward = Reward( + id = "", // Будет присвоен сервером + title = state.newRewardTitle, + description = state.newRewardDescription.takeIf { it.isNotBlank() }, + imageUrl = null, + pointsRequired = state.newRewardPoints, + isClaimed = false, + earnedAt = null, + userId = userId + ) + + val result = createRewardUseCase(newReward) + result.onSuccess { + _uiState.value = _uiState.value.copy( + isLoading = false, + showAddDialog = false, + newRewardTitle = "", + newRewardDescription = "", + newRewardPoints = 10 + ) + // Список обновится автоматически через Flow + }.onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = exception.message ?: "Ошибка создания награды" + ) + } + } + } + + fun deleteReward(rewardId: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + val result = deleteRewardUseCase(rewardId) + result.onSuccess { + _uiState.value = _uiState.value.copy(isLoading = false) + // Список обновится автоматически через Flow + }.onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = exception.message ?: "Ошибка удаления награды" + ) + } + } + } + + fun clearError() { + _uiState.value = _uiState.value.copy(errorMessage = null) } } data class RewardsUiState( val rewards: List = emptyList(), val isLoading: Boolean = false, + val errorMessage: String? = null, val showAddDialog: Boolean = false, - val newRewardTitle: String = "" + val newRewardTitle: String = "", + val newRewardDescription: String = "", + val newRewardPoints: Int = 10 )