diff --git a/build-logic/convention/src/main/java/AndroidLibraryPlugin.kt b/build-logic/convention/src/main/java/AndroidLibraryPlugin.kt index cb659bc3..d0b300ec 100644 --- a/build-logic/convention/src/main/java/AndroidLibraryPlugin.kt +++ b/build-logic/convention/src/main/java/AndroidLibraryPlugin.kt @@ -23,6 +23,7 @@ class AndroidLibraryPlugin : Plugin { dependencies { implementation(libs.getLibrary("timber")) + implementation(libs.getLibrary("gson")) } } } diff --git a/core/common/src/main/java/com/umcspot/spot/common/util/FileUtil.kt b/core/common/src/main/java/com/umcspot/spot/common/util/FileUtil.kt new file mode 100644 index 00000000..760f5948 --- /dev/null +++ b/core/common/src/main/java/com/umcspot/spot/common/util/FileUtil.kt @@ -0,0 +1,33 @@ +package com.umcspot.spot.common.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream + +object FileUtil { + fun createTempFileFromUri(context: Context, uri: Uri): File? { + return runCatching { + val inputStream = context.contentResolver.openInputStream(uri) + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + if (bitmap == null) return null + + val file = File.createTempFile("upload_image_", ".jpg", context.cacheDir) + + FileOutputStream(file).use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + } + + bitmap.recycle() + + file + }.onFailure { e -> + Timber.e(e, "이미지 압축 및 임시 파일 생성 실패") + }.getOrNull() + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SpotActivationButton.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SpotActivationButton.kt index 104e6927..6e127aac 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SpotActivationButton.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SpotActivationButton.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.G300 @@ -26,7 +27,8 @@ fun SpotActivationButton( buttonText: String, onClick: () -> Unit, modifier: Modifier = Modifier, - isEnabled: Boolean = false + isEnabled: Boolean = false, + style: TextStyle = SpotTheme.typography.h5 ) { val borderColor = if (isEnabled) SpotTheme.colors.B500 else SpotTheme.colors.G300 val textColor = if (isEnabled) SpotTheme.colors.B500 else SpotTheme.colors.G400 @@ -50,7 +52,7 @@ fun SpotActivationButton( ) { Text( text = buttonText, - style = SpotTheme.typography.h3, + style = style, color = textColor ) } diff --git a/core/designsystem/src/main/res/drawable/ic_check.xml b/core/designsystem/src/main/res/drawable/ic_check.xml new file mode 100644 index 00000000..72db5ba1 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/model/src/main/java/com/umcspot/spot/model/Global.kt b/core/model/src/main/java/com/umcspot/spot/model/Global.kt index 6829fffe..81fb9531 100644 --- a/core/model/src/main/java/com/umcspot/spot/model/Global.kt +++ b/core/model/src/main/java/com/umcspot/spot/model/Global.kt @@ -6,15 +6,15 @@ enum class WeatherType { HEAVYRAIN, RAIN, SNOW, WIND, COLD, HOT, SUNNY } enum class SortType { RECENT, RECOMMEND, COMMENT_COUNT } -enum class PostType { PASS_EXPERIENCE, INFORMATION_SHARING, COUNSELING, JOB_TALK, FREE_TALK} +enum class PostType { PASS_EXPERIENCE, INFORMATION_SHARING, COUNSELING, JOB_TALK, FREE_TALK } val PostType.korean: String get() = when (this) { - PostType.PASS_EXPERIENCE -> "합격후기" - PostType.INFORMATION_SHARING -> "정보공유" - PostType.COUNSELING -> "고민상담" - PostType.JOB_TALK -> "취준토크" - PostType.FREE_TALK -> "자유토크" + PostType.PASS_EXPERIENCE -> "합격후기" + PostType.INFORMATION_SHARING -> "정보공유" + PostType.COUNSELING -> "고민상담" + PostType.JOB_TALK -> "취준토크" + PostType.FREE_TALK -> "자유토크" } enum class RecruitingStudySort(val label: String) { @@ -27,7 +27,7 @@ enum class RecruitingStudySort(val label: String) { enum class AlertKind { POPULAR_POST, STUDY_NOTICE, STUDY_SCHEDULE, TODO_DONE } enum class ActivityType( - val label : String + val label: String ) { ONLINE("온라인"), OFFLINE("오프라인") @@ -35,7 +35,7 @@ enum class ActivityType( enum class FeeRange( val label: String -){ +) { NONE("없음"), UNDER_10K("1만원 미만"), ABOUT10K("1만원대"), @@ -60,6 +60,19 @@ enum class StudyTheme( OTHER("기타") } +enum class StudyStyle { + NETWORKING, + GOAL_OR_RULE_ORIENTED, + SHORT_TERM, + LONG_TERM, + INDIVIDUAL_PLUS_DISCUSSION, + GROUP_PLUS_SIMULTANEOUS, + LEARNING_BASED, + DISCUSSION_BASED, + LIGHT_AND_FLEXIBLE, + STRUCTURED_AND_PLANNED +} + enum class SocialLoginType( val title: String ) { diff --git a/core/ui/src/main/java/com/umcspot/spot/ui/extension/GsonExt.kt b/core/ui/src/main/java/com/umcspot/spot/ui/extension/GsonExt.kt new file mode 100644 index 00000000..de048de4 --- /dev/null +++ b/core/ui/src/main/java/com/umcspot/spot/ui/extension/GsonExt.kt @@ -0,0 +1,17 @@ +package com.umcspot.spot.ui.extension + +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import com.google.gson.Gson + +fun Any.toRequestBody(): RequestBody { + val jsonString = Gson().toJson(this) + return jsonString.toRequestBody("application/json".toMediaTypeOrNull()) +} + +fun Any.toMultipartBodyPart(name: String): MultipartBody.Part { + val requestBody = this.toRequestBody() + return MultipartBody.Part.createFormData(name, null, requestBody) +} \ No newline at end of file diff --git a/data/study/build.gradle.kts b/data/study/build.gradle.kts index f26e4c21..a7a83092 100644 --- a/data/study/build.gradle.kts +++ b/data/study/build.gradle.kts @@ -7,4 +7,5 @@ android { } dependencies { implementation(projects.domain.study) + implementation(projects.core.ui) } \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt b/data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt index 087e231f..eef94bb4 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt @@ -3,14 +3,21 @@ package com.umcspot.spot.study.datasource import com.umcspot.spot.model.ActivityType import com.umcspot.spot.model.FeeRange import com.umcspot.spot.model.RecruitingStudySort -import com.umcspot.spot.model.SortType import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.network.model.BaseResponse import com.umcspot.spot.study.dto.request.StudyRequestDto +import com.umcspot.spot.study.dto.response.CreateStudyResponseDto import com.umcspot.spot.study.dto.response.StudyResponseDto +import java.io.File interface StudyDataSource { suspend fun getPopularStudies(): BaseResponse suspend fun getRecommendStudies(): BaseResponse - suspend fun getRecruitingStudies(sortType : RecruitingStudySort, activityType: ActivityType, theme: StudyTheme, feeRange: FeeRange): BaseResponse + suspend fun getRecruitingStudies( + sortType: RecruitingStudySort, + activityType: ActivityType, + theme: StudyTheme, + feeRange: FeeRange + ): BaseResponse + suspend fun createStudy(request: StudyRequestDto, imageFile: File?): BaseResponse } \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt b/data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt index 7fc907df..ab830b4d 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt @@ -3,12 +3,18 @@ package com.umcspot.spot.study.datasourceimpl import com.umcspot.spot.model.ActivityType import com.umcspot.spot.model.FeeRange import com.umcspot.spot.model.RecruitingStudySort -import com.umcspot.spot.model.SortType import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.network.model.BaseResponse import com.umcspot.spot.study.datasource.StudyDataSource +import com.umcspot.spot.study.dto.request.StudyRequestDto +import com.umcspot.spot.study.dto.response.CreateStudyResponseDto import com.umcspot.spot.study.dto.response.StudyResponseDto import com.umcspot.spot.study.service.StudyService +import com.umcspot.spot.ui.extension.toMultipartBodyPart +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File import javax.inject.Inject class StudyDataSourceImpl @Inject constructor( @@ -22,7 +28,24 @@ class StudyDataSourceImpl @Inject constructor( ): BaseResponse = studyService.getRecommendStudies() - override suspend fun getRecruitingStudies(sortType : RecruitingStudySort, activityType: ActivityType, theme: StudyTheme, feeRange: FeeRange) : BaseResponse = + override suspend fun getRecruitingStudies( + sortType: RecruitingStudySort, + activityType: ActivityType, + theme: StudyTheme, + feeRange: FeeRange + ): BaseResponse = studyService.getRecruitingStudies(sortType, activityType, theme, feeRange) + override suspend fun createStudy( + request: StudyRequestDto, + imageFile: File? + ): BaseResponse { + val requestPart = request.toMultipartBodyPart("request") + val imagePart = imageFile?.let { file -> + val requestBody = file.asRequestBody("image/*".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("imageFile", file.name, requestBody) + } + return studyService.createStudy(requestPart, imagePart) + } + } \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/request/StudyRequestDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/request/StudyRequestDto.kt index d7749527..a84fce95 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/dto/request/StudyRequestDto.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/request/StudyRequestDto.kt @@ -1,12 +1,33 @@ package com.umcspot.spot.study.dto.request -import android.annotation.SuppressLint -import kotlinx.serialization.SerialName +import com.umcspot.spot.model.StudyStyle +import com.umcspot.spot.model.StudyTheme import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName -@SuppressLint("UnsafeOptInUsageError") @Serializable data class StudyRequestDto( - @SerialName("studyId") - val studyId : Int -) + @SerialName("name") + val name: String, + + @SerialName("maxMembers") + val maxMembers: Int, + + @SerialName("hasFee") + val hasFee: Boolean, + + @SerialName("amount") + val amount: Int, + + @SerialName("description") + val description: String, + + @SerialName("categories") + val categories: List, + + @SerialName("styles") + val styles: List, + + @SerialName("regionCodes") + val regionCodes: List +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/CreateStudyResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/CreateStudyResponseDto.kt new file mode 100644 index 00000000..e6bd76f7 --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/CreateStudyResponseDto.kt @@ -0,0 +1,10 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateStudyResponseDto( + @SerialName("studyId") + val studyId: Long +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt b/data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt index 8cbdb16a..72cde6a4 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt @@ -3,29 +3,32 @@ package com.umcspot.spot.study.mapper import com.umcspot.spot.study.dto.request.StudyRequestDto import com.umcspot.spot.study.dto.response.Study as DTOStudy import com.umcspot.spot.study.dto.response.StudyResponseDto -import com.umcspot.spot.study.model.Study as DomainStudy +import com.umcspot.spot.study.model.StudyCreateModel import com.umcspot.spot.study.model.StudyResult import com.umcspot.spot.study.model.StudyResultList -// Domain -> Request DTO -fun DomainStudy.toData(): StudyRequestDto = - StudyRequestDto( - studyId = this.studyId - ) +fun StudyCreateModel.toData(): StudyRequestDto = StudyRequestDto( + name = this.name, + maxMembers = this.maxMembers, + hasFee = this.hasFee, + amount = this.amount, + description = this.description, + categories = this.categories, + styles = this.styles, + regionCodes = this.regionCodes +) -fun DTOStudy.toDomain() : StudyResult = - StudyResult ( - studyId = this.studyId, - title = this.title, - goal = this.goal, - maxMember = this.maxMember, - member = this.member, - likes = this.likes, - views = this.views, - studyImage = this.studyImage - ) +fun DTOStudy.toDomain(): StudyResult = StudyResult( + studyId = this.studyId, + title = this.title, + goal = this.goal, + maxMember = this.maxMember, + member = this.member, + likes = this.likes, + views = this.views, + studyImage = this.studyImage +) -fun StudyResponseDto.toDomainList(): StudyResultList = - StudyResultList( - studyList = this.studyList.map(DTOStudy::toDomain) - ) \ No newline at end of file +fun StudyResponseDto.toDomain(): StudyResultList = StudyResultList( + studyList = this.studyList.map { it.toDomain() } +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt b/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt index f093851f..4d8daeab 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt @@ -4,53 +4,75 @@ import com.umcspot.spot.model.ActivityType import com.umcspot.spot.model.FeeRange import com.umcspot.spot.model.RecruitingStudySort import com.umcspot.spot.model.StudyTheme -import com.umcspot.spot.study.mapper.toDomainList +import com.umcspot.spot.study.datasource.StudyDataSource +import com.umcspot.spot.study.mapper.toData +import com.umcspot.spot.study.mapper.toDomain +import com.umcspot.spot.study.model.StudyCreateModel import com.umcspot.spot.study.model.StudyResultList import com.umcspot.spot.study.repository.StudyRepository -import com.umcspot.spot.study.service.StudyService +import java.io.File import javax.inject.Inject class StudyRepositoryImpl @Inject constructor( - private val studyService: StudyService + private val studyDataSource: StudyDataSource ) : StudyRepository { + override suspend fun getPopularStudies(): Result = runCatching { - val response = studyService.getPopularStudies() - response.result!!.toDomainList() - }.recoverCatching { - setPopularDummies() + val response = studyDataSource.getPopularStudies() + response.result.toDomain() } - private fun setPopularDummies(count: Int = 5): StudyResultList = - StudyResultList(StudyResultList.getPopularDummies(count)) - - override suspend fun getRecommendStudies(): Result = runCatching { - val response = studyService.getPopularStudies() - response.result!!.toDomainList() - }.recoverCatching { - setRecommendDummies() + val response = studyDataSource.getRecommendStudies() + response.result.toDomain() } - private fun setRecommendDummies(count: Int = 5): StudyResultList = - StudyResultList(StudyResultList.getRecommendedDummies(count)) - - - override suspend fun getRecruitingStudies(sortType : RecruitingStudySort, activityType: ActivityType?, theme: StudyTheme?, feeRange: FeeRange?): Result = + override suspend fun getRecruitingStudies( + sortType: RecruitingStudySort, + activityType: ActivityType?, + theme: StudyTheme?, + feeRange: FeeRange? + ): Result = runCatching { - val response = studyService.getRecruitingStudies(sortType = sortType, activityType = activityType, theme = theme, feeRange = feeRange) - response.result!!.toDomainList() - }.recoverCatching { - setRecommendDummies(30) + val response = studyDataSource.getRecruitingStudies( + sortType = sortType, + activityType = activityType ?: ActivityType.OFFLINE, + theme = theme ?: StudyTheme.OTHER, + feeRange = feeRange ?: FeeRange.NONE + ) + response.result.toDomain() } - override suspend fun getPreferLocationStudies(sortType : RecruitingStudySort, activityType: ActivityType?, theme: StudyTheme?, feeRange: FeeRange?): Result = + override suspend fun getPreferLocationStudies( + sortType: RecruitingStudySort, + activityType: ActivityType?, + theme: StudyTheme?, + feeRange: FeeRange? + ): Result = runCatching { - val response = studyService.getRecruitingStudies(sortType = sortType, activityType = activityType, theme = theme, feeRange = feeRange) - response.result!!.toDomainList() - }.recoverCatching { - setRecommendDummies(0) + val response = studyDataSource.getRecruitingStudies( + sortType = sortType, + activityType = activityType ?: ActivityType.OFFLINE, + theme = theme ?: StudyTheme.OTHER, + feeRange = feeRange ?: FeeRange.NONE + ) + response.result.toDomain() + } + + override suspend fun createStudy( + studyCreateModel: StudyCreateModel, + imageFile: File? + ): Result = runCatching { + val requestDto = studyCreateModel.toData() + + val response = studyDataSource.createStudy(requestDto, imageFile) + + if (!response.isSuccess) { + throw Exception(response.message ?: "스터디 생성 실패") } + response.result.studyId + } } \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt b/data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt index 19925a12..f1e48fa5 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt @@ -5,10 +5,13 @@ import com.umcspot.spot.model.FeeRange import com.umcspot.spot.model.RecruitingStudySort import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.network.model.BaseResponse -import com.umcspot.spot.study.dto.request.StudyRequestDto +import com.umcspot.spot.study.dto.response.CreateStudyResponseDto import com.umcspot.spot.study.dto.response.StudyResponseDto -import retrofit2.http.Body +import okhttp3.MultipartBody import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part import retrofit2.http.Query interface StudyService { @@ -30,4 +33,11 @@ interface StudyService { @Query("theme") theme: StudyTheme?, @Query("feeRange") feeRange: FeeRange? ): BaseResponse + + @Multipart + @POST("/api/studies") + suspend fun createStudy( + @Part request: MultipartBody.Part, + @Part imageFile: MultipartBody.Part? + ): BaseResponse } \ No newline at end of file diff --git a/domain/study/src/main/java/com/umcspot/spot/study/model/StudyCreateModel.kt b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyCreateModel.kt new file mode 100644 index 00000000..410c65f2 --- /dev/null +++ b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyCreateModel.kt @@ -0,0 +1,56 @@ +package com.umcspot.spot.study.model + +import com.umcspot.spot.model.StudyStyle +import com.umcspot.spot.model.StudyTheme + +data class StudyCreateModel( + val name: String, + val maxMembers: Int, + val hasFee: Boolean, + val amount: Int, + val description: String, + val categories: List, + val styles: List, + val regionCodes: List +) + +enum class StudyPersonality( + val title: String, + val leftLabel: String, + val rightLabel: String, + val option1: StudyStyle, + val option2: StudyStyle +) { + NETWORKING( + "네트워킹", + "네트워킹 중시", + "목표/규율 중시", + StudyStyle.NETWORKING, + StudyStyle.GOAL_OR_RULE_ORIENTED + ), + DURATION("진행 기간", "단기 목표", "장기 목표", StudyStyle.SHORT_TERM, StudyStyle.LONG_TERM), + TYPE( + "진행 방식", + "개인 학습 + 함께 토론형", + "공동 학습 + 동시 진행형", + StudyStyle.INDIVIDUAL_PLUS_DISCUSSION, + StudyStyle.GROUP_PLUS_SIMULTANEOUS + ), + FOCUS("주요 활동", "학습형", "토론형", StudyStyle.LEARNING_BASED, StudyStyle.DISCUSSION_BASED), + ATMOSPHERE( + "분위기", + "가볍게 + 유연하게", + "규칙적인 + 계획적인", + StudyStyle.LIGHT_AND_FLEXIBLE, + StudyStyle.STRUCTURED_AND_PLANNED + ); + + + fun getStyle(index: Int): StudyStyle { + return if (index == 0) option1 else option2 + } + + companion object { + val entriesList = entries.toList() + } +} \ No newline at end of file diff --git a/domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt b/domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt index ed8f5dd0..4811c49b 100644 --- a/domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt +++ b/domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt @@ -4,13 +4,26 @@ import com.umcspot.spot.model.ActivityType import com.umcspot.spot.model.FeeRange import com.umcspot.spot.model.RecruitingStudySort import com.umcspot.spot.model.StudyTheme -import com.umcspot.spot.study.model.Study -import com.umcspot.spot.study.model.StudyResult +import com.umcspot.spot.study.model.StudyCreateModel import com.umcspot.spot.study.model.StudyResultList +import java.io.File interface StudyRepository { suspend fun getPopularStudies(): Result suspend fun getRecommendStudies(): Result - suspend fun getRecruitingStudies(sortType : RecruitingStudySort, activityType: ActivityType?, theme: StudyTheme?, feeRange: FeeRange?): Result - suspend fun getPreferLocationStudies(sortType : RecruitingStudySort, activityType: ActivityType?, theme: StudyTheme?, feeRange: FeeRange?): Result + suspend fun getRecruitingStudies( + sortType: RecruitingStudySort, + activityType: ActivityType?, + theme: StudyTheme?, + feeRange: FeeRange? + ): Result + + suspend fun getPreferLocationStudies( + sortType: RecruitingStudySort, + activityType: ActivityType?, + theme: StudyTheme?, + feeRange: FeeRange? + ): Result + + suspend fun createStudy(studyCreateModel: StudyCreateModel, imageFile: File?): Result } \ No newline at end of file diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt index 38bfe87c..8d536f9c 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt @@ -24,10 +24,12 @@ import com.umcspot.spot.feature.board.post.content.navigation.postContentGraph import com.umcspot.spot.feature.board.post.posting.navigation.navigateToPostingEdit import com.umcspot.spot.feature.board.post.posting.navigation.postingGraph import com.umcspot.spot.signup.navigation.signupGraph +import com.umcspot.spot.study.detail.navigation.studyDetailGraph import com.umcspot.spot.study.my.navigation.myStudyGraph import com.umcspot.spot.study.preferLocation.navigation.preferLocationStudyGraph import com.umcspot.spot.study.recruiting.navigation.recruitingStudyFilterGraph import com.umcspot.spot.study.recruiting.navigation.recruitingStudyGraph +import com.umcspot.spot.study.register.navigation.RegisterStudy import com.umcspot.spot.study.register.navigation.registerStudyGraph @Composable @@ -141,8 +143,20 @@ fun MainNavHost( registerStudyGraph( contentPadding = contentPadding, - onBackClick = { navigator.popBackStack() }, - navigateToHome = { navigator.navigateToHome() }, + onBackClick = { navigator.navigateToHome(clearStackNavOptions) }, + navigateToStudyDetail = { studyId -> + navigator.navigateToStudyDetail( + studyId = studyId, + navOptions = navOptions { + popUpTo { inclusive = true } + } + ) + } + ) + + studyDetailGraph( + contentPadding = contentPadding, + onBackClick = { navigator.navigateToMyStudy(clearStackNavOptions) } ) } } \ No newline at end of file diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt index 8524955a..48a05f6e 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt @@ -30,6 +30,8 @@ import com.umcspot.spot.signup.navigation.SignUp import com.umcspot.spot.signup.navigation.navigateToCheckList import com.umcspot.spot.signup.navigation.navigateToSaving import com.umcspot.spot.signup.navigation.navigateToSignUp +import com.umcspot.spot.study.detail.navigation.StudyDetail +import com.umcspot.spot.study.detail.navigation.navigateToStudyDetail import com.umcspot.spot.study.my.navigation.navigateToMyStudy import com.umcspot.spot.study.preferLocation.navigation.PreferLocation import com.umcspot.spot.study.preferLocation.navigation.navigateToPreferLocationStudy @@ -37,7 +39,6 @@ import com.umcspot.spot.study.recruiting.navigation.Recruiting import com.umcspot.spot.study.recruiting.navigation.RecruitingFilter import com.umcspot.spot.study.recruiting.navigation.navigateToRecruitingStudy import com.umcspot.spot.study.recruiting.navigation.navigateToRecruitingStudyFilter -import com.umcspot.spot.study.register.navigation.RegisterStudy import com.umcspot.spot.study.register.navigation.navigateToRegisterStudy import kotlin.reflect.KClass @@ -94,7 +95,7 @@ class MainNavigator( @Composable fun showBackTopBar(): Boolean = inAnyGraph(Alert::class, AppliedAlert::class, RecruitingFilter::class, - SignUp::class, CheckList::class,RegisterStudy::class, Posting::class, BoardList::class) || inAnyGraphRoutes(POST_CONTENT_ROUTE) + SignUp::class, CheckList::class, Posting::class, BoardList::class) || inAnyGraphRoutes(POST_CONTENT_ROUTE) @Composable fun showToTopFab(): Boolean = inAnyGraph(Alert::class, AppliedAlert::class, Recruiting::class,PreferLocation::class, BoardList::class) @@ -142,6 +143,10 @@ class MainNavigator( navController.navigateToHome(navOptions) } + fun navigateToMyStudy(navOptions: NavOptions? = null) { + navController.navigateToMyStudy(navOptions) + } + fun navigateToRegisterStudy(navOptions: NavOptions? = null) { navController.navigateToRegisterStudy(navOptions) } @@ -169,6 +174,11 @@ class MainNavigator( fun navigateToAppliedAlert(navOptions: NavOptions? = null) { navController.navigateToAppliedAlert(navOptions) } + + fun navigateToStudyDetail(studyId: Long, navOptions: NavOptions? = null) { + navController.navigateToStudyDetail(studyId, navOptions) + } + fun navigateToHomeAfterLogin() { val navOptions = navOptions { popUpTo(Landing) { inclusive = true } diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt index 0544d451..53696997 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt @@ -36,6 +36,7 @@ import com.umcspot.spot.feature.board.post.posting.navigation.Posting import com.umcspot.spot.feature.board.post.posting.navigation.navigateToPostingNew import com.umcspot.spot.signup.navigation.CheckList import com.umcspot.spot.signup.navigation.SignUp +import com.umcspot.spot.study.detail.navigation.StudyDetail import com.umcspot.spot.study.recruiting.navigation.RecruitingFilter import com.umcspot.spot.study.register.navigation.RegisterStudy import kotlinx.collections.immutable.toImmutableList @@ -54,9 +55,11 @@ fun MainScreen( Scaffold( topBar = { if (!navigator.isInLanding()) { - if (dest?.hasRoute(RegisterStudy::class) == true) { - } - else if (navigator.showBackTopBar()) { + val isRegisterOrDetail = dest?.hasRoute(RegisterStudy::class) == true || + dest?.hasRoute(StudyDetail::class) == true + + if (isRegisterOrDetail) { + } else if (navigator.showBackTopBar()) { val title = when { dest?.hasRoute(Alert::class) == true -> "알림" dest?.hasRoute(AppliedAlert::class) == true -> "신청한 알림" @@ -108,7 +111,7 @@ fun MainScreen( ) }, bottomBar = { - if(!navigator.isInLanding()) { + if (!navigator.isInLanding()) { MainBottomBar( visible = navigator.showBottomBar(), tabs = MainNavTab.entries.toImmutableList(), diff --git a/feature/study/src/main/java/com/umcspot/spot/study/component/SpotStudyDialog.kt b/feature/study/src/main/java/com/umcspot/spot/study/component/SpotStudyDialog.kt new file mode 100644 index 00000000..a11d5d7d --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/component/SpotStudyDialog.kt @@ -0,0 +1,117 @@ +package com.umcspot.spot.study.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.button.SpotActivationButton +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun SpotStudyDialog( + onDismissRequest: () -> Unit, + title: String, + description: String, + buttonText: String, + onButtonClick: () -> Unit, + modifier: Modifier = Modifier +) { + Dialog(onDismissRequest = onDismissRequest) { + SpotStudyDialogContent( + onDismissRequest = onDismissRequest, + title = title, + description = description, + buttonText = buttonText, + onButtonClick = onButtonClick, + modifier = modifier + ) + } +} + +@Composable +private fun SpotStudyDialogContent( + onDismissRequest: () -> Unit, + title: String, + description: String, + buttonText: String, + onButtonClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = SpotTheme.colors.white, + shape = RoundedCornerShape(14.dp) + ) + .padding(17.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + IconButton( + onClick = onDismissRequest, + modifier = Modifier + .size(20.dp) + .align(Alignment.End) + ) { + Icon( + painter = painterResource(id = R.drawable.dismiss), + contentDescription = "닫기", + ) + } + + Icon( + painter = painterResource(id = R.drawable.ic_check), + contentDescription = "완료", + modifier = Modifier.size(33.dp), + tint = SpotTheme.colors.B500 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(7.dp))) + + Text( + text = title, + style = SpotTheme.typography.h2, + color = SpotTheme.colors.black, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + Text( + text = description, + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + SpotActivationButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = screenWidthDp(68.dp)), + buttonText = buttonText, + isEnabled = true, + onClick = onButtonClick, + style = SpotTheme.typography.h5 + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt new file mode 100644 index 00000000..8d88606c --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt @@ -0,0 +1,79 @@ +package com.umcspot.spot.study.detail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.component.appBar.BackTopBar +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun StudyDetailRoute( + contentPadding: PaddingValues, + studyId: Long, + onBackClick: () -> Unit +) { + BackHandler { + onBackClick() + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + ) { + Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) + + BackTopBar( + title = "스터디", + onBackClick = onBackClick, + modifier = Modifier.fillMaxWidth() + ) + + StudyDetailScreen( + studyId = studyId, + modifier = Modifier.padding(bottom = contentPadding.calculateBottomPadding()) + ) + } +} + +@Composable +private fun StudyDetailScreen( + studyId: Long, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = screenWidthDp(17.dp)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "스터디 상세 화면", + style = SpotTheme.typography.h1, + color = SpotTheme.colors.black + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "생성된 스터디 ID: $studyId", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.B500 + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt new file mode 100644 index 00000000..24921af4 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt @@ -0,0 +1,33 @@ +package com.umcspot.spot.study.detail.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import com.umcspot.spot.navigation.Route +import com.umcspot.spot.study.detail.StudyDetailRoute +import kotlinx.serialization.Serializable + +fun NavController.navigateToStudyDetail(studyId: Long, navOptions: NavOptions? = null) { + navigate(StudyDetail(studyId), navOptions) +} + +fun NavGraphBuilder.studyDetailGraph( + contentPadding: PaddingValues, + onBackClick: () -> Unit +) { + composable { backStackEntry -> + val detail = backStackEntry.toRoute() + + StudyDetailRoute( + contentPadding = contentPadding, + studyId = detail.studyId, + onBackClick = onBackClick + ) + } +} + +@Serializable +data class StudyDetail(val studyId: Long) : Route \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt index 58f00ae0..b1cdfe69 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt @@ -12,9 +12,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,6 +26,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.umcspot.spot.designsystem.component.appBar.BackTopBar import com.umcspot.spot.designsystem.component.button.SpotActivationButton import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.component.SpotStudyDialog import com.umcspot.spot.study.register.component.StepProgressBar import com.umcspot.spot.study.register.model.RegisterStudySideEffect import com.umcspot.spot.study.register.model.RegisterStudyState @@ -33,7 +36,6 @@ import com.umcspot.spot.study.register.screen.StudyIntroduceScreen import com.umcspot.spot.study.register.screen.StudyPlaceScreen import com.umcspot.spot.ui.extension.screenHeightDp import com.umcspot.spot.ui.extension.screenWidthDp -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -42,7 +44,7 @@ import kotlinx.coroutines.launch fun RegisterStudyRoute( contentPadding: PaddingValues, onBackClick: () -> Unit, - navigateToHome: () -> Unit, + navigateToStudyDetail: (Long) -> Unit, modifier: Modifier = Modifier, viewModel: RegisterStudyViewModel = hiltViewModel() ) { @@ -50,6 +52,8 @@ fun RegisterStudyRoute( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val handleBackPress: () -> Unit = { if (pagerState.currentPage > 0) { coroutineScope.launch { @@ -62,11 +66,27 @@ fun RegisterStudyRoute( BackHandler { handleBackPress() } + if (uiState.isSuccessModalVisible) { + SpotStudyDialog( + onDismissRequest = { + uiState.createdStudyId?.let(navigateToStudyDetail) + }, + title = "스터디 등록 완료", + description = "이제 스터디 모집이 시작됩니다!\n마이페이지에서 신청을 수락할 수 있어요.", + buttonText = "내 스터디 보러가기", + onButtonClick = { + uiState.createdStudyId?.let(navigateToStudyDetail) + } + ) + } + + LaunchedEffect(viewModel.sideEffect) { viewModel.sideEffect.collectLatest { effect -> when (effect) { - is RegisterStudySideEffect.NavigateToHome -> navigateToHome() - else -> {} + is RegisterStudySideEffect.ShowSnackBar -> { + snackbarHostState.showSnackbar(effect.message) + } } } } @@ -107,7 +127,8 @@ fun RegisterStudyRoute( onMemberCountChange = viewModel::onMemberCountChange, onFeeInfoChange = viewModel::onFeeInfoChange, onPersonalityChange = viewModel::onPersonalityChange, - onDescriptionChange = viewModel::onDescriptionChange + onDescriptionChange = viewModel::onDescriptionChange, + onImageSelected = viewModel::onImageSelected ) } } @@ -131,7 +152,8 @@ private fun RegisterStudyScreen( onMemberCountChange: (Int) -> Unit, onFeeInfoChange: (Boolean?, String) -> Unit, onPersonalityChange: (Int, Int) -> Unit, - onDescriptionChange: (String) -> Unit + onDescriptionChange: (String) -> Unit, + onImageSelected: (String?) -> Unit ) { val coroutineScope = rememberCoroutineScope() @@ -162,6 +184,7 @@ private fun RegisterStudyScreen( onStudyNameChange = onStudyNameChange, onThemeSelect = onThemeSelect ) + 1 -> StudyPlaceScreen( activityType = uiState.activityType, isSheetVisible = uiState.isSheetVisible, @@ -175,24 +198,22 @@ private fun RegisterStudyScreen( onAddSelected = onAddSelected, onRemoveSelected = onRemoveSelected ) + 2 -> StudyInfoScreen( memberCount = uiState.memberCount, onMemberCountChange = onMemberCountChange, hasFee = uiState.hasFee, feeAmount = uiState.feeAmount, onFeeInfoChange = onFeeInfoChange, - preferences = persistentListOf( - uiState.networkingPreference, - uiState.goalDurationPreference, - uiState.discussionPreference, - uiState.learningPreference, - uiState.flexibilityPreference - ), + selectedStyles = uiState.personalitySelections, onPersonalityChange = onPersonalityChange ) + 3 -> StudyIntroduceScreen( description = uiState.description, onDescriptionChange = onDescriptionChange, + selectedImageUri = uiState.studyImageUri, + onImageSelected = onImageSelected, onIntroduceValid = { } ) } diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt index 3ca2a74e..4f4853cc 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt @@ -6,9 +6,13 @@ import androidx.lifecycle.viewModelScope import com.umcspot.spot.common.location.LocationRow import com.umcspot.spot.common.location.LocationStore import com.umcspot.spot.common.location.searchLocations +import com.umcspot.spot.common.util.FileUtil import com.umcspot.spot.model.ActivityType +import com.umcspot.spot.study.model.StudyCreateModel +import com.umcspot.spot.study.model.StudyPersonality import com.umcspot.spot.study.register.model.RegisterStudySideEffect import com.umcspot.spot.study.register.model.RegisterStudyState +import com.umcspot.spot.study.repository.StudyRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -21,10 +25,12 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +import androidx.core.net.toUri @HiltViewModel class RegisterStudyViewModel @Inject constructor( - @ApplicationContext private val appContext: Context + @ApplicationContext private val appContext: Context, + private val studyRepository: StudyRepository ) : ViewModel() { private val _uiState = MutableStateFlow(RegisterStudyState()) @@ -76,7 +82,10 @@ class RegisterStudyViewModel @Inject constructor( } fun addSelectedRegion(region: String) { - if (_uiState.value.selectedRegions.size < 10 && !_uiState.value.selectedRegions.contains(region)) { + if (_uiState.value.selectedRegions.size < 10 && !_uiState.value.selectedRegions.contains( + region + ) + ) { _uiState.update { it.copy( selectedRegions = it.selectedRegions + region @@ -130,16 +139,14 @@ class RegisterStudyViewModel @Inject constructor( _uiState.update { it.copy(hasFee = hasFee, feeAmount = amount) } } - fun onPersonalityChange(categoryIndex: Int, value: Int) { + fun onPersonalityChange(categoryIndex: Int, selectedOptionIndex: Int) { + val category = StudyPersonality.entriesList.getOrNull(categoryIndex) ?: return + val selectedStyle = category.getStyle(selectedOptionIndex) + _uiState.update { - when (categoryIndex) { - 0 -> it.copy(networkingPreference = value) - 1 -> it.copy(goalDurationPreference = value) - 2 -> it.copy(discussionPreference = value) - 3 -> it.copy(learningPreference = value) - 4 -> it.copy(flexibilityPreference = value) - else -> it - } + it.copy( + personalitySelections = it.personalitySelections + (category to selectedStyle) + ) } } @@ -155,24 +162,70 @@ class RegisterStudyViewModel @Inject constructor( if (state.activityType == null) return false if (state.activityType == ActivityType.OFFLINE) state.selectedRegions.isNotEmpty() else true } + 2 -> { - val isFeeValid = state.hasFee != null && (!state.hasFee || state.feeAmount.isNotBlank()) - val isPersonalityValid = state.networkingPreference != null && - state.goalDurationPreference != null && - state.discussionPreference != null && - state.learningPreference != null && - state.flexibilityPreference != null + val isFeeValid = + state.hasFee != null && (!state.hasFee || state.feeAmount.isNotBlank()) + val isPersonalityValid = + state.personalitySelections.size == StudyPersonality.entries.size state.memberCount > 1 && isFeeValid && isPersonalityValid } + 3 -> state.description.isNotBlank() else -> false } } + fun onImageSelected(uri: String?) { + _uiState.update { it.copy(studyImageUri = uri) } + } + fun submit() { viewModelScope.launch { - _sideEffect.emit(RegisterStudySideEffect.NavigateToHome) + val currentState = _uiState.value + + val imageFile = currentState.studyImageUri?.let { uriString -> + FileUtil.createTempFileFromUri(appContext, uriString.toUri()) + } + + val regionCodes = if (currentState.activityType == ActivityType.ONLINE) { + emptyList() + } else { + currentState.selectedRegions.mapNotNull { regionName -> + allLocations.find { it.name == regionName }?.code + } + } + + val styles = currentState.personalitySelections.values.toList() + + val createModel = StudyCreateModel( + name = currentState.studyName, + maxMembers = currentState.memberCount, + hasFee = currentState.hasFee ?: false, + amount = currentState.feeAmount.toIntOrNull() ?: 0, + description = currentState.description, + categories = currentState.studyThemes, + styles = styles, + regionCodes = regionCodes + ) + + studyRepository.createStudy(createModel, imageFile) + .onSuccess { id -> + _uiState.update { + it.copy( + isSuccessModalVisible = true, + createdStudyId = id + ) + } + } + .onFailure { exception -> + _sideEffect.emit( + RegisterStudySideEffect.ShowSnackBar( + exception.message ?: "실패" + ) + ) + } } } -} \ No newline at end of file +} diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt index 8ddfd3bd..a022dec1 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt @@ -2,10 +2,11 @@ package com.umcspot.spot.study.register.model import com.umcspot.spot.common.location.LocationRow import com.umcspot.spot.model.ActivityType +import com.umcspot.spot.model.StudyStyle import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.study.model.StudyPersonality data class RegisterStudyState( - val studyName: String = "", val studyThemes: List = emptyList(), @@ -19,17 +20,15 @@ data class RegisterStudyState( val hasFee: Boolean? = null, val feeAmount: String = "", - val networkingPreference: Int? = null, - val goalDurationPreference: Int? = null, - val discussionPreference: Int? = null, - val learningPreference: Int? = null, - val flexibilityPreference: Int? = null, + val personalitySelections: Map = emptyMap(), val description: String = "", - val studyImageUri: String? = null + val studyImageUri: String? = null, + + val isSuccessModalVisible: Boolean = false, + val createdStudyId: Long? = null ) sealed interface RegisterStudySideEffect { - data object NavigateToHome : RegisterStudySideEffect data class ShowSnackBar(val message: String) : RegisterStudySideEffect } \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/navigation/RegisterStudyNavigation.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/navigation/RegisterStudyNavigation.kt index b552447d..9018064c 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/navigation/RegisterStudyNavigation.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/navigation/RegisterStudyNavigation.kt @@ -18,18 +18,14 @@ fun NavController.navigateToRegisterStudy(navOptions: NavOptions? = null) { fun NavGraphBuilder.registerStudyGraph( contentPadding : PaddingValues, onBackClick: () -> Unit, -// navigateToNextScreen: (Long) -> Unit - navigateToHome: () -> Unit + navigateToStudyDetail: (Long) -> Unit ) { composable { RegisterStudyRoute( contentPadding = contentPadding, onBackClick = onBackClick, -// navigateToNext = { createdStudyId -> -// navigateToNextScreen(createdStudyId) -// } - navigateToHome = navigateToHome + navigateToStudyDetail = navigateToStudyDetail ) } } diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt index 2e420cde..962da15f 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt @@ -12,12 +12,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.model.StudyStyle +import com.umcspot.spot.study.model.StudyPersonality import com.umcspot.spot.study.register.component.BinaryChoiceRow import com.umcspot.spot.study.register.component.FeeInputSection import com.umcspot.spot.study.register.component.MemberCountSelector import com.umcspot.spot.ui.extension.screenHeightDp -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf @Composable fun StudyInfoScreen( @@ -26,7 +26,7 @@ fun StudyInfoScreen( hasFee: Boolean?, feeAmount: String, onFeeInfoChange: (Boolean?, String) -> Unit, - preferences: ImmutableList, + selectedStyles: Map, onPersonalityChange: (Int, Int) -> Unit, modifier: Modifier = Modifier, ) { @@ -79,22 +79,24 @@ fun StudyInfoScreen( Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) - val choiceLabels = persistentListOf( - "네트워킹 중시" to "목표/규율 중시", - "단기 목표" to "장기 목표", - "개인 학습 + 함께 토론형" to "공동 학습 + 동시 진행형", - "학습형" to "토론형", - "가볍게 + 유연하게" to "규칙적인 + 계획적인" - ) + StudyPersonality.entries.forEachIndexed { index, personality -> + + val currentStyle = selectedStyles[personality] + + val selectedIndex = when (currentStyle) { + personality.option1 -> 0 + personality.option2 -> 1 + else -> null + } - choiceLabels.forEachIndexed { index, (left, right) -> BinaryChoiceRow( - leftText = left, - rightText = right, - selectedIndex = preferences[index], + leftText = personality.leftLabel, + rightText = personality.rightLabel, + selectedIndex = selectedIndex, onSelect = { value -> onPersonalityChange(index, value) } ) - if (index < choiceLabels.lastIndex) { + + if (index < StudyPersonality.entries.lastIndex) { Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) } } diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt index 90a8fe0e..9fa1dac4 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt @@ -1,6 +1,8 @@ package com.umcspot.spot.study.register.screen -import androidx.compose.foundation.Image +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -30,6 +32,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.umcspot.spot.designsystem.R import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.Default @@ -44,11 +47,17 @@ import com.umcspot.spot.ui.extension.screenWidthDp fun StudyIntroduceScreen( description: String, onDescriptionChange: (String) -> Unit, + selectedImageUri: String?, + onImageSelected: (String?) -> Unit, onIntroduceValid: (Boolean) -> Unit, modifier: Modifier = Modifier ) { - var isImageSelected by remember { mutableStateOf(false) } - + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + onImageSelected(uri?.toString()) + } + ) LaunchedEffect(description) { onIntroduceValid(description.isNotEmpty()) } @@ -128,15 +137,17 @@ fun StudyIntroduceScreen( modifier = Modifier .size(width = screenWidthDp(80.dp), height = screenHeightDp(80.dp)) .clip(RoundedCornerShape(6.dp)) - .background(if (isImageSelected) SpotTheme.colors.black else SpotTheme.colors.G100) // 이미지가 없을 때 배경색 지정 (G100) + .background(if (selectedImageUri != null) SpotTheme.colors.black else SpotTheme.colors.G100) .noRippleClickable { - isImageSelected = !isImageSelected + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) }, contentAlignment = Alignment.Center ) { - if (isImageSelected) { - Image( - painter = painterResource(id = R.drawable.license), + if (selectedImageUri != null) { + AsyncImage( + model = selectedImageUri, contentDescription = "Selected Study Image", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8814ba76..6354250a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,7 +70,6 @@ naverMapLocation = "21.0.2" naverMapSdk = "3.22.0" # Compose Versions -compose-compiler = "1.5.1" compose-bom = "2025.06.01" compose-material3 = "1.4.0" activity-compose = "1.8.2" @@ -94,7 +93,6 @@ kakao = "2.23.0" process-pheonix = "3.0.0" preference = "1.2.1" collapsing-toolbar = "2.3.5" -runtimeAndroid = "1.7.8" flexibleBottomSheet = "0.1.5" annotationJvm = "1.9.1" @@ -103,6 +101,9 @@ browser = "1.8.0" naver-oauth = "5.11.0" datastoreCore = "1.1.7" +# gson +gson = "2.10.1" + [plugins] # Gradle Plugins ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } @@ -263,7 +264,7 @@ kakao-common = { group = "com.kakao.sdk", name = "v2-all", version.ref = "kakao" process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "process-pheonix" } collapsing-toolbar = { group = "me.onebone", name = "toolbar-compose", version.ref = "collapsing-toolbar" } -androidx-runtime-android = { group = "androidx.compose.runtime", name = "runtime-android", version.ref = "runtimeAndroid" } +androidx-runtime = { group = "androidx.compose.runtime", name = "runtime" } flexible-bottomsheet = { group = "com.github.skydoves", name = "flexible-bottomsheet-material3", version.ref = "flexibleBottomSheet" } #CalendarLibrary @@ -275,6 +276,9 @@ naver-jdk = { module = "com.navercorp.nid:oauth-jdk8", version.ref = "naver-oaut androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" } +#Gson +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } + [bundles] coil = ["coil", "coil-svg", "coil-gif"] firebase = ["firebase-analytics", "firebase-database", "firebase-messaging", "firebase-remoteConfig"]