diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasource/AppjamtampDataSource.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasource/AppjamtampDataSource.kt index 680b550d5..4912061f3 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasource/AppjamtampDataSource.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasource/AppjamtampDataSource.kt @@ -1,14 +1,30 @@ package org.sopt.official.data.appjamtamp.datasource +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampMissionsResponseDto +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampPostStampResponseDto +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampStampResponseDto import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop10MissionScoreResponse import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop3RecentMissionResponse -import org.sopt.official.data.appjamtamp.dto.AppjamtampMissionsResponseDto interface AppjamtampDataSource { suspend fun getAppjamtampMissions( teamNumber: String? = null, isCompleted: Boolean? = null ): AppjamtampMissionsResponseDto + + suspend fun getAppjamtampStamp( + missionId: Int, + nickname: String + ): AppjamtampStampResponseDto + + suspend fun postAppjamtampStamp( + missionId: Int, + image: String, + contents: String, + activityDate: String + ): AppjamtampPostStampResponseDto + suspend fun getAppjamtampMissionTop3(size: Int): AppjamtampTop3RecentMissionResponse + suspend fun getAppjamtampMissionRanking(size: Int): AppjamtampTop10MissionScoreResponse } \ No newline at end of file diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasourceimpl/AppjamtampDataSourceImpl.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasourceimpl/AppjamtampDataSourceImpl.kt index 8cd6bd2e9..7b2212265 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasourceimpl/AppjamtampDataSourceImpl.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasourceimpl/AppjamtampDataSourceImpl.kt @@ -1,11 +1,14 @@ package org.sopt.official.data.appjamtamp.datasourceimpl +import javax.inject.Inject import org.sopt.official.data.appjamtamp.datasource.AppjamtampDataSource +import org.sopt.official.data.appjamtamp.dto.request.AppjamtampPostStampRequestDto +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampMissionsResponseDto +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampPostStampResponseDto +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampStampResponseDto import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop10MissionScoreResponse import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop3RecentMissionResponse -import org.sopt.official.data.appjamtamp.dto.AppjamtampMissionsResponseDto import org.sopt.official.data.appjamtamp.service.AppjamtampService -import javax.inject.Inject internal class AppjamtampDataSourceImpl @Inject constructor( private val appjamtampService: AppjamtampService @@ -16,6 +19,25 @@ internal class AppjamtampDataSourceImpl @Inject constructor( ): AppjamtampMissionsResponseDto = appjamtampService.getAppjamtampMissions(teamNumber, isCompleted) + override suspend fun getAppjamtampStamp( + missionId: Int, + nickname: String + ): AppjamtampStampResponseDto = appjamtampService.getAppjamtampStamp(missionId, nickname) + + override suspend fun postAppjamtampStamp( + missionId: Int, + image: String, + contents: String, + activityDate: String + ): AppjamtampPostStampResponseDto = appjamtampService.postAppjamtampStamp( + AppjamtampPostStampRequestDto( + missionId = missionId, + image = image, + contents = contents, + activityDate = activityDate + ) + ) + override suspend fun getAppjamtampMissionTop3(size: Int): AppjamtampTop3RecentMissionResponse = appjamtampService.getAppjamtampMissionTop3(size = size) diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/request/AppjamtampPostStampRequestDto.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/request/AppjamtampPostStampRequestDto.kt new file mode 100644 index 000000000..0d948c686 --- /dev/null +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/request/AppjamtampPostStampRequestDto.kt @@ -0,0 +1,16 @@ +package org.sopt.official.data.appjamtamp.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AppjamtampPostStampRequestDto( + @SerialName("missionId") + val missionId: Int, + @SerialName("image") + val image: String, + @SerialName("contents") + val contents: String, + @SerialName("activityDate") + val activityDate: String +) diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/AppjamtampMissionsResponseDto.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampMissionsResponseDto.kt similarity index 96% rename from data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/AppjamtampMissionsResponseDto.kt rename to data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampMissionsResponseDto.kt index 073897aef..5be22ffe5 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/AppjamtampMissionsResponseDto.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampMissionsResponseDto.kt @@ -1,4 +1,4 @@ -package org.sopt.official.data.appjamtamp.dto +package org.sopt.official.data.appjamtamp.dto.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampPostStampResponseDto.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampPostStampResponseDto.kt new file mode 100644 index 000000000..90384fddc --- /dev/null +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampPostStampResponseDto.kt @@ -0,0 +1,26 @@ +package org.sopt.official.data.appjamtamp.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AppjamtampPostStampResponseDto( + @SerialName("id") + val id: Int, + @SerialName("contents") + val contents: String, + @SerialName("images") + val images: List, + @SerialName("activityDate") + val activityDate: String, + @SerialName("createdAt") + val createdAt: String, + @SerialName("updatedAt") + val updatedAt: String, + @SerialName("missionId") + val missionId: Int, + @SerialName("clapCount") + val clapCount: Int, + @SerialName("viewCount") + val viewCount: Int +) diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampStampResponseDto.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampStampResponseDto.kt new file mode 100644 index 000000000..796fc6541 --- /dev/null +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampStampResponseDto.kt @@ -0,0 +1,38 @@ +package org.sopt.official.data.appjamtamp.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AppjamtampStampResponseDto( + @SerialName("id") + val id: Int, + @SerialName("contents") + val contents: String, + @SerialName("images") + val images: List, + @SerialName("activityDate") + val activityDate: String, + @SerialName("createdAt") + val createdAt: String, + @SerialName("updatedAt") + val updatedAt: String, + @SerialName("missionId") + val missionId: Int, + @SerialName("teamNumber") + val teamNumber: String, + @SerialName("teamName") + val teamName: String, + @SerialName("ownerNickname") + val ownerNickname: String, + @SerialName("ownerProfileImage") + val ownerProfileImage: String?, + @SerialName("clapCount") + val clapCount: Int, + @SerialName("viewCount") + val viewCount: Int, + @SerialName("myClapCount") + val myClapCount: Int, + @SerialName("isMine") + val isMine: Boolean +) diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/mapper/.gitkeep b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/mapper/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/mapper/AppjamtampStampMapper.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/mapper/AppjamtampStampMapper.kt new file mode 100644 index 000000000..2bbcd6913 --- /dev/null +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/mapper/AppjamtampStampMapper.kt @@ -0,0 +1,23 @@ +package org.sopt.official.data.appjamtamp.mapper + +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampStampResponseDto +import org.sopt.official.domain.appjamtamp.entity.AppjamtampStampEntity + +internal fun AppjamtampStampResponseDto.toEntity(): AppjamtampStampEntity = + AppjamtampStampEntity( + stampId = this.id, + contents = this.contents, + images = this.images, + activityDate = this.activityDate, + createdAt = this.createdAt, + updatedAt = this.updatedAt, + missionId = this.missionId, + teamNumber = this.teamNumber, + teamName = this.teamName, + ownerNickname = this.ownerNickname, + ownerProfileImage = this.ownerProfileImage, + clapCount = this.clapCount, + viewCount = this.viewCount, + myClapCount = this.myClapCount, + isMine = this.isMine + ) diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/repository/AppjamtampRepositoryImpl.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/repository/AppjamtampRepositoryImpl.kt index a16c1d060..00db1d5ad 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/repository/AppjamtampRepositoryImpl.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/repository/AppjamtampRepositoryImpl.kt @@ -1,13 +1,15 @@ package org.sopt.official.data.appjamtamp.repository +import javax.inject.Inject import org.sopt.official.common.coroutines.suspendRunCatching import org.sopt.official.data.appjamtamp.datasource.AppjamtampDataSource import org.sopt.official.data.appjamtamp.mapper.toDomain +import org.sopt.official.data.appjamtamp.mapper.toEntity import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionListEntity import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionScore import org.sopt.official.domain.appjamtamp.entity.AppjamtampRecentMission +import org.sopt.official.domain.appjamtamp.entity.AppjamtampStampEntity import org.sopt.official.domain.appjamtamp.repository.AppjamtampRepository -import javax.inject.Inject internal class AppjamtampRepositoryImpl @Inject constructor( private val appjamtampDataSource: AppjamtampDataSource @@ -19,6 +21,27 @@ internal class AppjamtampRepositoryImpl @Inject constructor( appjamtampDataSource.getAppjamtampMissions(teamNumber, isCompleted).toEntity() } + override suspend fun getAppjamtampStamp( + missionId: Int, + nickname: String + ): Result = suspendRunCatching { + appjamtampDataSource.getAppjamtampStamp(missionId, nickname).toEntity() + } + + override suspend fun postAppjamtampStamp( + missionId: Int, + image: String, + contents: String, + activityDate: String + ): Result = suspendRunCatching { + appjamtampDataSource.postAppjamtampStamp( + missionId = missionId, + image = image, + contents = contents, + activityDate = activityDate + ) + } + override suspend fun getAppjamtampMissionRanking( size: Int ): Result> = suspendRunCatching { diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/service/AppjamtampService.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/service/AppjamtampService.kt index d0e4b014d..97e22cc16 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/service/AppjamtampService.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/service/AppjamtampService.kt @@ -1,17 +1,33 @@ package org.sopt.official.data.appjamtamp.service -import org.sopt.official.data.appjamtamp.dto.AppjamtampMissionsResponseDto -import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop3RecentMissionResponse +import org.sopt.official.data.appjamtamp.dto.request.AppjamtampPostStampRequestDto +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampMissionsResponseDto +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampPostStampResponseDto +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampStampResponseDto import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop10MissionScoreResponse +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop3RecentMissionResponse +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Query interface AppjamtampService { @GET("appjamtamp/mission") suspend fun getAppjamtampMissions( @Query("teamNumber") teamNumber: String? = null, - @Query("isCompleted") isCompleted : Boolean? = null - ) : AppjamtampMissionsResponseDto + @Query("isCompleted") isCompleted: Boolean? = null + ): AppjamtampMissionsResponseDto + + @GET("appjamtamp/stamp") + suspend fun getAppjamtampStamp( + @Query("missionId") missionId: Int, + @Query("nickname") nickname: String + ): AppjamtampStampResponseDto + + @POST("appjamtamp/stamp") + suspend fun postAppjamtampStamp( + @Body body: AppjamtampPostStampRequestDto + ): AppjamtampPostStampResponseDto /** * 앱잼에 참여하는 전체 팀의 득점 랭킹 조회 diff --git a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampStampEntity.kt b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampStampEntity.kt new file mode 100644 index 000000000..f3db45673 --- /dev/null +++ b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampStampEntity.kt @@ -0,0 +1,19 @@ +package org.sopt.official.domain.appjamtamp.entity + +data class AppjamtampStampEntity( + val stampId: Int, + val contents: String, + val images: List, + val activityDate: String, + val createdAt: String, + val updatedAt: String, + val missionId: Int, + val teamNumber: String, + val teamName: String, + val ownerNickname: String, + val ownerProfileImage: String?, + val clapCount: Int, + val viewCount: Int, + val myClapCount: Int, + val isMine: Boolean +) diff --git a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/repository/AppjamtampRepository.kt b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/repository/AppjamtampRepository.kt index b6a803179..41269cebd 100644 --- a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/repository/AppjamtampRepository.kt +++ b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/repository/AppjamtampRepository.kt @@ -3,15 +3,30 @@ package org.sopt.official.domain.appjamtamp.repository import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionListEntity import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionScore import org.sopt.official.domain.appjamtamp.entity.AppjamtampRecentMission +import org.sopt.official.domain.appjamtamp.entity.AppjamtampStampEntity interface AppjamtampRepository { suspend fun getAppjamtampMissions( teamNumber: String? = null, isCompleted: Boolean? = null ): Result + + suspend fun getAppjamtampStamp( + missionId: Int, + nickname: String + ): Result + + suspend fun postAppjamtampStamp( + missionId: Int, + image: String, + contents: String, + activityDate: String + ): Result + suspend fun getAppjamtampMissionRanking( size: Int ): Result> + suspend fun getAppjamtampMissionTop3( size: Int ): Result> diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailRoute.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailRoute.kt index 504d95d41..cf7077b97 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailRoute.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailRoute.kt @@ -11,7 +11,9 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -22,9 +24,14 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.collections.immutable.persistentListOf +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.flowWithLifecycle import org.sopt.official.designsystem.SoptTheme -import org.sopt.official.domain.appjamtamp.entity.MissionLevel +import org.sopt.official.designsystem.component.dialog.TwoButtonDialog import org.sopt.official.feature.appjamtamp.component.AppjamtampButton import org.sopt.official.feature.appjamtamp.component.BackButtonHeader import org.sopt.official.feature.appjamtamp.missiondetail.component.ClapFeedbackHolder @@ -33,47 +40,151 @@ import org.sopt.official.feature.appjamtamp.missiondetail.component.DataPickerBo import org.sopt.official.feature.appjamtamp.missiondetail.component.DatePicker import org.sopt.official.feature.appjamtamp.missiondetail.component.DetailInfo import org.sopt.official.feature.appjamtamp.missiondetail.component.ImageContent +import org.sopt.official.feature.appjamtamp.missiondetail.component.ImageModal import org.sopt.official.feature.appjamtamp.missiondetail.component.Memo import org.sopt.official.feature.appjamtamp.missiondetail.component.MissionHeader import org.sopt.official.feature.appjamtamp.missiondetail.component.ProfileTag import org.sopt.official.feature.appjamtamp.missiondetail.model.DetailViewType import org.sopt.official.feature.appjamtamp.model.ImageModel -import org.sopt.official.feature.appjamtamp.model.Mission import org.sopt.official.feature.appjamtamp.model.Stamp -import org.sopt.official.feature.appjamtamp.model.User @Composable internal fun MissionDetailRoute( - + navigateUp: () -> Unit, + viewModel: MissionDetailViewModel = hiltViewModel() ) { + val lifecycleOwner = LocalLifecycleOwner.current + + val uiState by viewModel.missionDetailState.collectAsStateWithLifecycle() + + var isDatePickerVisible by remember { mutableStateOf(false) } var isClapBottomSheetVisible by remember { mutableStateOf(false) } + var isImageZoomInDialogVisible by remember { mutableStateOf(false) } + var selectedZoomInImage by remember { mutableStateOf(null) } + var isDeleteDialogVisible by remember { mutableStateOf(false) } + + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + viewModel.flushClap() + } + + LaunchedEffect(viewModel.sideEffect, lifecycleOwner) { + viewModel.sideEffect.flowWithLifecycle(lifecycleOwner.lifecycle) + .collect { sideEffect -> + when (sideEffect) { + is MissionDetailSideEffect.NavigateUp -> navigateUp() + } + } + } + + if (uiState.viewType == DetailViewType.WRITE) { + MyEmptyMissionDetailScreen( + uiState = uiState, + onBackButtonClick = navigateUp, + onChangeImage = viewModel::updateImageModel, + onClickZoomIn = { image -> + isImageZoomInDialogVisible = true + selectedZoomInImage = image + }, + onDatePickerClick = { isDatePickerVisible = true }, + onMemoChange = viewModel::updateContent, + onCompleteButtonClick = viewModel::handleSubmit + ) + } else { + MissionDetailScreen( + uiState = uiState, + onBackButtonClick = navigateUp, + onToolbarIconClick = { + when (uiState.viewType) { + DetailViewType.COMPLETE -> { + viewModel.updateViewType(DetailViewType.EDIT) + } + + DetailViewType.EDIT -> { + isDeleteDialogVisible = true + } + + else -> {} + } + }, + onChangeImage = viewModel::updateImageModel, + onClickZoomIn = { image -> + isImageZoomInDialogVisible = true + selectedZoomInImage = image + }, + onDatePickerClick = { isDatePickerVisible = true }, + onMemoChange = viewModel::updateContent, + onActionButtonClick = { + when (uiState.viewType) { + DetailViewType.WRITE -> viewModel.handleSubmit() + DetailViewType.READ_ONLY -> viewModel.onClap() + DetailViewType.COMPLETE -> { + viewModel.getClappersList() + isClapBottomSheetVisible = true + } + + DetailViewType.EDIT -> viewModel.handleSubmit() + } + } + ) + } + + if (isDatePickerVisible) { + DataPickerBottomSheet( + onSelected = { + viewModel.updateDate(it) + isDatePickerVisible = false + }, + onDismissRequest = { isDatePickerVisible = false } + ) + } if (isClapBottomSheetVisible) { ClapUserBottomDialog( onDismiss = { isClapBottomSheetVisible = false }, - userList = persistentListOf(), - onClickUser = { _, _ -> } + userList = uiState.clappers, + onClickUser = { _, _ -> /* Nothing to do */ } ) } + + if (isImageZoomInDialogVisible) { + ImageModal( + image = selectedZoomInImage.orEmpty(), + onDismiss = { + isImageZoomInDialogVisible = false + selectedZoomInImage = null + } + ) + } + + if (isDeleteDialogVisible) { + TwoButtonDialog( + onDismiss = { isDeleteDialogVisible = false }, + positiveButtonText = "삭제", + negativeButtonText = "취소", + onPositiveClick = viewModel::deleteMission, + onNegativeClick = { isDeleteDialogVisible = false } + ) { + Text( + text = "달성한 미션을 삭제하시겠습니까?", + style = SoptTheme.typography.body16M, + color = SoptTheme.colors.primary, + ) + } + } } @Composable private fun MyEmptyMissionDetailScreen( - mission: Mission, - imageModel: ImageModel, - date: String, - content: String, + uiState: MissionDetailState, onBackButtonClick: () -> Unit, onChangeImage: (ImageModel) -> Unit, onClickZoomIn: (String) -> Unit, + onDatePickerClick: () -> Unit, onMemoChange: (String) -> Unit, - onCompleteButtonClick: () -> Unit, - onDateSelected: (String) -> Unit + onCompleteButtonClick: () -> Unit ) { val scrollState = rememberScrollState() - var isDatePickerVisible by remember { mutableStateOf(false) } - Column( modifier = Modifier .fillMaxSize() @@ -89,14 +200,14 @@ private fun MyEmptyMissionDetailScreen( Spacer(modifier = Modifier.height(10.dp)) MissionHeader( - title = mission.title, - stamp = Stamp.findStampByLevel(mission.level) + title = uiState.mission.title, + stamp = Stamp.findStampByLevel(uiState.mission.level) ) Spacer(modifier = Modifier.height(5.dp)) ImageContent( - imageModel = imageModel, + imageModel = uiState.imageModel, onChangeImage = onChangeImage, onClickZoomIn = onClickZoomIn, isEditable = true @@ -105,16 +216,16 @@ private fun MyEmptyMissionDetailScreen( Spacer(modifier = Modifier.height(15.dp)) DatePicker( - value = date, + value = uiState.date, placeHolder = "날짜를 입력해주세요.", isEditable = true, - onClicked = { isDatePickerVisible = true } + onClicked = onDatePickerClick ) Spacer(modifier = Modifier.height(8.dp)) Memo( - value = content, + value = uiState.content, placeHolder = "함께한 사람과 어떤 추억을 남겼는지 작성해 주세요.", onValueChange = onMemoChange, isEditable = true @@ -130,39 +241,21 @@ private fun MyEmptyMissionDetailScreen( .padding(bottom = 20.dp) ) } - - if (isDatePickerVisible) { - DataPickerBottomSheet( - onSelected = onDateSelected, - onDismissRequest = { isDatePickerVisible = false } - ) - } } @Composable private fun MissionDetailScreen( - viewType: DetailViewType, - title: String, - mission: Mission, - imageModel: ImageModel, - date: String, - content: String, - writer: User, - clapCount: Int, - myClapCount: Int, - viewCount: Int, + uiState: MissionDetailState, onBackButtonClick: () -> Unit, onToolbarIconClick: () -> Unit, onChangeImage: (ImageModel) -> Unit, onClickZoomIn: (String) -> Unit, + onDatePickerClick: () -> Unit, onMemoChange: (String) -> Unit, - onActionButtonClick: () -> Unit, - onDateSelected: (String) -> Unit + onActionButtonClick: () -> Unit ) { val scrollState = rememberScrollState() - - var isDatePickerVisible by remember { mutableStateOf(false) } - var isEditable by remember(viewType) { mutableStateOf(viewType == DetailViewType.EDIT) } + var isEditable by remember(uiState.viewType) { mutableStateOf(uiState.viewType == DetailViewType.EDIT) } Column( modifier = Modifier @@ -172,12 +265,12 @@ private fun MissionDetailScreen( .verticalScroll(scrollState) ) { BackButtonHeader( - title = title, + title = if (uiState.viewType == DetailViewType.COMPLETE) "내 미션" else uiState.teamName, onBackButtonClick = onBackButtonClick, trailingIcon = { - viewType.toolbarIcon?.let { + uiState.viewType.toolbarIcon?.let { Icon( - imageVector = ImageVector.vectorResource(viewType.toolbarIcon), + imageVector = ImageVector.vectorResource(uiState.viewType.toolbarIcon), contentDescription = null, tint = SoptTheme.colors.onSurface10, modifier = Modifier @@ -190,14 +283,14 @@ private fun MissionDetailScreen( Spacer(modifier = Modifier.height(10.dp)) MissionHeader( - title = mission.title, - stamp = Stamp.findStampByLevel(mission.level) + title = uiState.mission.title, + stamp = Stamp.findStampByLevel(uiState.mission.level) ) Spacer(modifier = Modifier.height(5.dp)) ImageContent( - imageModel = imageModel, + imageModel = uiState.imageModel, onChangeImage = onChangeImage, onClickZoomIn = onClickZoomIn, isEditable = isEditable @@ -206,25 +299,25 @@ private fun MissionDetailScreen( Spacer(modifier = Modifier.height(12.dp)) ProfileTag( - name = writer.name, - profileImage = writer.profileImage + name = uiState.writer.name, + profileImage = uiState.writer.profileImage ) Spacer(modifier = Modifier.height(16.dp)) if (isEditable) { DatePicker( - value = date, + value = uiState.date, placeHolder = "날짜를 입력해주세요.", isEditable = true, - onClicked = { isDatePickerVisible = true } + onClicked = onDatePickerClick ) Spacer(modifier = Modifier.height(8.dp)) } Memo( - value = content, + value = uiState.content, placeHolder = "함께한 사람과 어떤 추억을 남겼는지 작성해 주세요.", onValueChange = onMemoChange, isEditable = isEditable @@ -232,18 +325,18 @@ private fun MissionDetailScreen( Spacer(modifier = Modifier.height(8.dp)) DetailInfo( - date = date, - clapCount = clapCount, - viewCount = viewCount + date = uiState.date, + clapCount = uiState.clapCount, + viewCount = uiState.viewCount ) Spacer(modifier = Modifier.weight(1f)) - when (viewType) { - DetailViewType.DEFAULT -> { + when (uiState.viewType) { + DetailViewType.READ_ONLY -> { ClapFeedbackHolder( - clapCount = clapCount, - myClapCount = myClapCount, + clapCount = uiState.clapCount, + myClapCount = uiState.myClapCount, onPressClap = onActionButtonClick, modifier = Modifier .fillMaxWidth() @@ -261,7 +354,7 @@ private fun MissionDetailScreen( ) } - DetailViewType.EDIT -> { + DetailViewType.EDIT, DetailViewType.WRITE -> { AppjamtampButton( text = "미션 완료", onClicked = onActionButtonClick, @@ -271,14 +364,6 @@ private fun MissionDetailScreen( ) } } - - } - - if (isDatePickerVisible) { - DataPickerBottomSheet( - onSelected = onDateSelected, - onDismissRequest = { isDatePickerVisible = false } - ) } } @@ -287,21 +372,13 @@ private fun MissionDetailScreen( private fun MyEmptyMissionDetailScreenPreview() { SoptTheme { MyEmptyMissionDetailScreen( - mission = Mission( - id = 1, - title = "앱잼 팀원 다 함께 바다 보고 오기", - level = MissionLevel.of(1), - isCompleted = false - ), - imageModel = ImageModel.Empty, - date = "", - content = "", + uiState = MissionDetailState(), onBackButtonClick = {}, onChangeImage = {}, onClickZoomIn = {}, + onDatePickerClick = {}, onMemoChange = {}, - onCompleteButtonClick = {}, - onDateSelected = {} + onCompleteButtonClick = {} ) } } @@ -311,31 +388,12 @@ private fun MyEmptyMissionDetailScreenPreview() { private fun MyMissionDetailScreenPreview() { SoptTheme { MissionDetailScreen( - viewType = DetailViewType.DEFAULT, - title = "내 미션", - mission = Mission( - id = 1, - title = "앱잼 팀원 다 함께 바다 보고 오기", - level = MissionLevel.of(1), - isCompleted = false - ), - imageModel = ImageModel.Remote( - url = listOf("https://avatars.githubusercontent.com/u/98209004?v=4") - ), - date = "", - content = "", - writer = User( - name = "터닝박효빈", - profileImage = "https://avatars.githubusercontent.com/u/98209004?v=4" - ), - clapCount = 10, - myClapCount = 0, - viewCount = 100, + uiState = MissionDetailState(), onBackButtonClick = {}, onChangeImage = {}, onClickZoomIn = {}, + onDatePickerClick = {}, onMemoChange = {}, - onDateSelected = {}, onActionButtonClick = {}, onToolbarIconClick = {} ) diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailSideEffect.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailSideEffect.kt new file mode 100644 index 000000000..5e47ae629 --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailSideEffect.kt @@ -0,0 +1,5 @@ +package org.sopt.official.feature.appjamtamp.missiondetail + +internal sealed interface MissionDetailSideEffect { + data object NavigateUp : MissionDetailSideEffect +} diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailState.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailState.kt new file mode 100644 index 000000000..cbb323876 --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailState.kt @@ -0,0 +1,30 @@ +package org.sopt.official.feature.appjamtamp.missiondetail + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.sopt.official.feature.appjamtamp.missiondetail.model.DetailViewType +import org.sopt.official.feature.appjamtamp.missiondetail.model.StampClapUserModel +import org.sopt.official.feature.appjamtamp.model.ImageModel +import org.sopt.official.feature.appjamtamp.model.Mission +import org.sopt.official.feature.appjamtamp.model.User + +internal data class MissionDetailState( + val isLoading: Boolean = true, + val isFailed: Boolean = false, + + val viewType: DetailViewType = DetailViewType.WRITE, + val mission: Mission = Mission.DEFAULT, + val imageModel: ImageModel = ImageModel.Empty, + val date: String = "", + val content: String = "", + + val stampId: Int = -1, + val writer: User = User(), + val teamName: String = "", + val clapCount: Int = 0, + val myClapCount: Int = 0, + val unSyncedClapCount: Int = 0, + val viewCount: Int = 0, + + val clappers: ImmutableList = persistentListOf() +) diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailViewModel.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailViewModel.kt new file mode 100644 index 000000000..05cf12d31 --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailViewModel.kt @@ -0,0 +1,335 @@ +package org.sopt.official.feature.appjamtamp.missiondetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.sopt.official.common.coroutines.suspendRunCatching +import org.sopt.official.domain.appjamtamp.entity.MissionLevel +import org.sopt.official.domain.appjamtamp.repository.AppjamtampRepository +import org.sopt.official.domain.soptamp.model.Stamp +import org.sopt.official.domain.soptamp.model.StampClap +import org.sopt.official.domain.soptamp.repository.ImageUploaderRepository +import org.sopt.official.domain.soptamp.repository.StampRepository +import org.sopt.official.feature.appjamtamp.missiondetail.model.DetailViewType +import org.sopt.official.feature.appjamtamp.missiondetail.model.toUiModel +import org.sopt.official.feature.appjamtamp.missiondetail.navigation.AppjamtampMissionDetail +import org.sopt.official.feature.appjamtamp.model.ImageModel +import org.sopt.official.feature.appjamtamp.model.Mission +import org.sopt.official.feature.appjamtamp.model.User +import timber.log.Timber + +@OptIn(FlowPreview::class) +@HiltViewModel +internal class MissionDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val appjamtampRepository: AppjamtampRepository, + private val stampRepository: StampRepository, + private val imageUploaderRepository: ImageUploaderRepository, +) : ViewModel() { + private val route: AppjamtampMissionDetail = savedStateHandle.toRoute() + + private val _missionDetailState = MutableStateFlow(MissionDetailState()) + val missionDetailState: StateFlow + get() = _missionDetailState.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect: SharedFlow + get() = _sideEffect.asSharedFlow() + + private var isClapLoading = false + + init { + viewModelScope.launch { + _missionDetailState + .map { it.unSyncedClapCount } + .filter { it > 0 } + .debounce(2_000) + .collect { + postClap() + } + } + + initMissionState() + } + + private fun initMissionState() { + if (route.ownerName == null) { + _missionDetailState.update { + it.copy( + isLoading = false, + viewType = DetailViewType.WRITE, + mission = Mission( + id = route.missionId, + title = route.title, + level = MissionLevel.of(route.missionLevel), + isCompleted = false + ) + ) + } + } else { + getMissionContent(missionId = route.missionId, name = route.ownerName) + } + } + + private fun getMissionContent(missionId: Int, name: String) { + viewModelScope.launch { + appjamtampRepository.getAppjamtampStamp(missionId = missionId, nickname = name) + .onSuccess { stamp -> + val viewType = if (stamp.isMine) DetailViewType.COMPLETE else DetailViewType.READ_ONLY + _missionDetailState.update { + it.copy( + isLoading = false, + viewType = viewType, + mission = Mission( + id = route.missionId, + title = route.title, + level = MissionLevel.of(route.missionLevel), + isCompleted = true + ), + imageModel = ImageModel.Remote(stamp.images), + date = stamp.activityDate, + content = stamp.contents, + teamName = stamp.teamName, + stampId = stamp.stampId, + writer = User( + name = stamp.ownerNickname, + profileImage = stamp.ownerProfileImage.orEmpty() + ), + clapCount = stamp.clapCount, + myClapCount = stamp.myClapCount, + viewCount = stamp.viewCount + ) + } + + }.onFailure { e -> + _missionDetailState.update { + it.copy(isLoading = false, isFailed = true) + } + Timber.e(e) + } + } + } + + fun getClappersList() { + _missionDetailState.update { + it.copy(isLoading = true) + } + + viewModelScope.launch { + stampRepository.getClappers(_missionDetailState.value.stampId) + .onSuccess { clappers -> + _missionDetailState.update { + it.copy( + isLoading = false, + clappers = clappers.clappers.map { it.toUiModel() }.toImmutableList() + ) + } + }.onFailure { e -> + _missionDetailState.update { + it.copy(isLoading = false, isFailed = true) + } + Timber.e(e) + } + } + } + + fun onClap() { + if (_missionDetailState.value.myClapCount >= 50) return + + _missionDetailState.update { + it.copy( + clapCount = it.clapCount + 1, + myClapCount = it.myClapCount + 1, + unSyncedClapCount = it.unSyncedClapCount + 1 + ) + } + } + + fun flushClap() { + postClap() + } + + fun handleSubmit() { + _missionDetailState.update { + it.copy(isLoading = true) + } + + if (_missionDetailState.value.viewType == DetailViewType.WRITE) { + submitMission() + } else { + modifyMission() + } + } + + private fun submitMission() { + viewModelScope.launch { + uploadImage() + + with(_missionDetailState.value) { + if (imageModel is ImageModel.Remote) { + appjamtampRepository.postAppjamtampStamp( + missionId = mission.id, + image = imageModel.url[0], + contents = content, + activityDate = date + ).onSuccess { + _missionDetailState.update { + it.copy( + isLoading = false, + viewType = DetailViewType.COMPLETE + ) + } + }.onFailure { e -> + _missionDetailState.update { + it.copy(isLoading = false, isFailed = true) + } + Timber.e(e) + } + } + } + } + } + + private fun modifyMission() { + viewModelScope.launch { + if (_missionDetailState.value.imageModel is ImageModel.Local) { + uploadImage() + } + + with(_missionDetailState.value) { + if (imageModel is ImageModel.Remote) { + stampRepository.modifyMission( + Stamp( + missionId = mission.id, + image = imageModel.url[0], + contents = content, + activityDate = date + ) + ).onSuccess { + _missionDetailState.update { + it.copy( + isLoading = false, + viewType = DetailViewType.COMPLETE + ) + } + }.onFailure { e -> + _missionDetailState.update { + it.copy(isLoading = false, isFailed = true) + } + Timber.e(e) + } + } + } + } + } + + fun deleteMission() { + _missionDetailState.update { + it.copy(isLoading = true) + } + + viewModelScope.launch { + stampRepository.deleteMission(_missionDetailState.value.mission.id) + .onSuccess { + _sideEffect.emit(MissionDetailSideEffect.NavigateUp) + } + .onFailure { e -> + _missionDetailState.update { + it.copy(isLoading = false, isFailed = true) + } + Timber.e(e) + } + } + } + + private suspend fun uploadImage() { + val image = (_missionDetailState.value.imageModel as? ImageModel.Local)?.uri ?: return + + imageUploaderRepository.getImageUploadURL() + .onSuccess { s3Url -> + val presignedURL = s3Url.preSignedURL + val imageUrl = s3Url.imageURL + + suspendRunCatching { + imageUploaderRepository.uploadImage( + preSignedURL = presignedURL, + imageUri = image[0] + ) + }.onSuccess { + _missionDetailState.update { + it.copy( + imageModel = ImageModel.Remote(listOf(imageUrl)) + ) + } + }.onFailure(Timber::e) + + }.onFailure { + _missionDetailState.update { + it.copy(isLoading = false, isFailed = true) + } + } + } + + private fun postClap() { + val state = _missionDetailState.value + if (isClapLoading || state.unSyncedClapCount <= 0) return + + isClapLoading = true + + viewModelScope.launch { + stampRepository.clapStamp(stampId = state.stampId, clap = StampClap(clapCount = state.unSyncedClapCount)) + .onSuccess { clapResult -> + _missionDetailState.update { + it.copy( + clapCount = clapResult.totalClapCount, + unSyncedClapCount = 0 + ) + } + }.onFailure(Timber::e) + + isClapLoading = false + } + } + + fun updateViewType(viewType: DetailViewType) { + _missionDetailState.update { + it.copy( + viewType = viewType + ) + } + } + + fun updateImageModel(imageModel: ImageModel) { + _missionDetailState.update { + it.copy(imageModel = imageModel) + } + } + + fun updateDate(value: String) { + _missionDetailState.update { + it.copy(date = value) + } + } + + fun updateContent(value: String) { + _missionDetailState.update { + it.copy(content = value) + } + } +} diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/DetailViewType.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/DetailViewType.kt index 1e7924509..3c5dc252c 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/DetailViewType.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/DetailViewType.kt @@ -6,7 +6,8 @@ import org.sopt.official.feature.appjamtamp.R enum class DetailViewType( @field:DrawableRes val toolbarIcon: Int? = null ) { - DEFAULT, + WRITE, + READ_ONLY, COMPLETE(R.drawable.ic_write_32), EDIT(R.drawable.ic_delete_32) } diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/StampClapUserModel.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/StampClapUserModel.kt new file mode 100644 index 000000000..4d3b9cad2 --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/StampClapUserModel.kt @@ -0,0 +1,17 @@ +package org.sopt.official.feature.appjamtamp.missiondetail.model + +import org.sopt.official.domain.soptamp.model.StampClappers + +data class StampClapUserModel( + val nickname: String, + val profileImage: String?, + val profileMessage: String?, + val clapCount: Int +) + +fun StampClappers.StampClapUser.toUiModel() = StampClapUserModel( + nickname = nickname, + profileImage = profileImage, + profileMessage = profileMessage, + clapCount = clapCount +) diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/StampClapUserUiModel.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/StampClapUserUiModel.kt deleted file mode 100644 index 4763d1dbd..000000000 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/model/StampClapUserUiModel.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.sopt.official.feature.appjamtamp.missiondetail.model - -data class StampClapUserModel( - val nickname: String, - val profileImage: String?, - val profileMessage: String?, - val clapCount: Int -) diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/navigation/AppjamtampMissionDetailNavigation.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/navigation/AppjamtampMissionDetailNavigation.kt new file mode 100644 index 000000000..87e906ed6 --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/navigation/AppjamtampMissionDetailNavigation.kt @@ -0,0 +1,12 @@ +package org.sopt.official.feature.appjamtamp.missiondetail.navigation + +import kotlinx.serialization.Serializable +import org.sopt.official.core.navigation.Route + +@Serializable +data class AppjamtampMissionDetail( + val missionId: Int = -1, + val missionLevel: Int = 1, + val title: String = "", + val ownerName: String? = null +) : Route diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/model/Mission.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/model/Mission.kt index 1099a5946..ab4b7b5db 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/model/Mission.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/model/Mission.kt @@ -7,4 +7,13 @@ internal data class Mission( val title: String, val level: MissionLevel, val isCompleted: Boolean -) +) { + companion object { + val DEFAULT = Mission( + id = -1, + title = "", + level = MissionLevel.of(1), + isCompleted = false + ) + } +} diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/model/User.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/model/User.kt index 8514bb026..065f070b5 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/model/User.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/model/User.kt @@ -1,6 +1,6 @@ package org.sopt.official.feature.appjamtamp.model internal data class User( - val name: String, - val profileImage: String + val name: String = "", + val profileImage: String = "" )