diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Spinner.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Spinner.kt index 03e3ef41..60f822ba 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Spinner.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Spinner.kt @@ -23,7 +23,7 @@ import com.umcspot.spot.ui.extension.screenWidthDp @Composable fun SpotSpinner( modifier: Modifier = Modifier, - size: Dp = 24.dp, + size: Dp = screenWidthDp(24.dp), speed: Float = 1f, isPlaying: Boolean = true, iterations: Int = LottieConstants.IterateForever, @@ -53,7 +53,7 @@ fun SpotSpinner( speed = speed, dynamicProperties = dynamicProps, modifier = modifier - .size(screenWidthDp(size)) + .size(size) .semantics { if (contentDescription != null) this.contentDescription = contentDescription } diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/StudyItem.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/StudyItem.kt index 98d94296..8705c5d9 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/StudyItem.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/StudyItem.kt @@ -2,17 +2,15 @@ package com.umcspot.spot.designsystem.component.study import androidx.annotation.DrawableRes import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,12 +23,10 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.umcspot.spot.designsystem.R import com.umcspot.spot.designsystem.component.button.ClickSurface import com.umcspot.spot.designsystem.shapes.SpotShapes -import com.umcspot.spot.designsystem.theme.G300 import com.umcspot.spot.designsystem.theme.SpotTheme import com.umcspot.spot.model.ImageRef import com.umcspot.spot.study.model.StudyResult @@ -42,15 +38,17 @@ fun StudyListItem( item: StudyResult, modifier: Modifier = Modifier, onClick: (StudyResult) -> Unit = {}, + meetballSlot: (@Composable () -> Unit)? = null, + checkAppliedSlot: (@Composable () -> Unit)? = null ) { ClickSurface( - onClick = { onClick(item)}, + onClick = { onClick(item) }, modifier = modifier ) { Row( modifier = Modifier.padding(screenWidthDp(7.dp)), horizontalArrangement = Arrangement.spacedBy(13.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.Top ) { StudyThumbnail( imageRef = item.profileImageUrl, @@ -62,22 +60,31 @@ fun StudyListItem( // 텍스트 + 통계 Column( modifier = Modifier - .fillMaxWidth() + .wrapContentWidth() .height(screenHeightDp(73.dp)) .padding(screenHeightDp(4.dp)) - ){ - Text( - text = item.name, - style = SpotTheme.typography.h5, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = item.description, - style = SpotTheme.typography.regular_400, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + ) { + Row{ + Column{ + Text( + text = item.name, + style = SpotTheme.typography.h5, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = item.description, + style = SpotTheme.typography.regular_400, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (checkAppliedSlot != null) { + Spacer(modifier = Modifier.weight(1f)) + + checkAppliedSlot() + } + } Spacer(modifier = Modifier.weight(1f)) @@ -95,6 +102,11 @@ fun StudyListItem( ) } } + Spacer(modifier = Modifier.weight(1f)) + + if (meetballSlot != null) { + meetballSlot() + } } } } @@ -109,6 +121,9 @@ private fun Stat( val display = if (count1 != 0) "${cap(count1)} / ${cap(count2)}" else cap(count2) Row( + modifier = Modifier + .width(screenWidthDp(56.dp)) + .height(screenHeightDp(17.dp)), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(screenWidthDp(4.dp)) ) { @@ -142,6 +157,7 @@ fun StudyThumbnail( modifier = modifier ) } + is ImageRef.Url -> { AsyncImage( model = img.url, @@ -151,6 +167,7 @@ fun StudyThumbnail( modifier = modifier ) } + ImageRef.None, null -> { Image( painter = painterResource(placeholder), @@ -174,10 +191,10 @@ fun StudyThumbnail( /* ============== Preview ============== */ -@Preview(showBackground = true, widthDp = 300) +@Preview(showBackground = true, widthDp = 326) @Composable private fun StudyListItemPreview() { - SpotTheme{ + SpotTheme { StudyListItem( item = StudyResult( id = 1, @@ -189,9 +206,10 @@ private fun StudyListItemPreview() { isLiked = false, hitCount = 1200, profileImageUrl = ImageRef.Name("spot_logo"), + isOwner = false ), modifier = Modifier.padding(10.dp), - onClick = {} + onClick = {}, ) } } 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 5d2e34c5..f55976b0 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 @@ -38,4 +38,10 @@ interface StudyDataSource { cursor: Long?, size: Int ): BaseResponse + + suspend fun getMyPageStudy( + statuses : List, + cursor: Long?, + size: Int + ): 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 01ebafda..7af51c6c 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 @@ -84,4 +84,15 @@ class StudyDataSourceImpl @Inject constructor( cursor = cursor, size = size ) + + override suspend fun getMyPageStudy( + statuses: List, + cursor: Long?, + size: Int + ): BaseResponse = + studyService.getMyPageStudy( + statuses = statuses, + cursor = cursor, + size = size + ) } \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyResponseDto.kt index ef5e2b8b..0cba9d94 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyResponseDto.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyResponseDto.kt @@ -42,6 +42,9 @@ data class Study ( @SerialName("isLiked") val isLiked: Boolean, + @SerialName("isOwner") + val isOwner: Boolean, + @SerialName("hitCount") val hitCount: Int = 0, 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 5e1b90fe..8d05342e 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 @@ -28,6 +28,7 @@ fun Study.toDomain() : StudyResult = currentMembers = this.currentMembers, likeCount = this.likeCount, isLiked = this.isLiked, + isOwner = this.isOwner, hitCount = this.hitCount, profileImageUrl = this.profileImageUrl.toImageRef() ) 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 9b007297..332a14b3 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 @@ -129,4 +129,49 @@ class StudyRepositoryImpl @Inject constructor( }.onFailure { e -> Log.e("StudyRepository", "getLikedStudies failed", e) } + + override suspend fun getParticipatingStudy( + cursor: Long?, + size: Int + ): Result = + runCatching { + val response = studyDataSource.getMyPageStudy( + statuses = listOf("OWNER","APPROVED"), + cursor = cursor, + size = size + ) + response.result.toDomainList() + }.onFailure { e -> + Log.e("StudyRepository", "getParticipatingStudy failed", e) + } + + override suspend fun getRecruitingStudy( + cursor: Long?, + size: Int + ): Result = + runCatching { + val response = studyDataSource.getMyPageStudy( + statuses = listOf("OWNER"), + cursor = cursor, + size = size + ) + response.result.toDomainList() + }.onFailure { e -> + Log.e("StudyRepository", "getRecruitingStudy failed", e) + } + + override suspend fun getWaitingStudy( + cursor: Long?, + size: Int + ): Result = + runCatching { + val response = studyDataSource.getMyPageStudy( + statuses = listOf("APPLIED"), + cursor = cursor, + size = size + ) + response.result.toDomainList() + }.onFailure { e -> + Log.e("StudyRepository", "getRecruitingStudy failed", e) + } } \ 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 9655734f..0980fecc 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 @@ -66,4 +66,11 @@ interface StudyService { @Part request: MultipartBody.Part, @Part imageFile: MultipartBody.Part? ): BaseResponse + + @GET("/api/studies/me") + suspend fun getMyPageStudy( + @Query("statuses") statuses: List, + @Query("cursor") cursor: Long?, + @Query("size") size: Int + ): BaseResponse } \ No newline at end of file diff --git a/data/weather/src/main/java/com/umcspot/spot/weather/dto/response/WeatherResponseDto.kt b/data/weather/src/main/java/com/umcspot/spot/weather/dto/response/WeatherResponseDto.kt index 607583ff..9cb635cf 100644 --- a/data/weather/src/main/java/com/umcspot/spot/weather/dto/response/WeatherResponseDto.kt +++ b/data/weather/src/main/java/com/umcspot/spot/weather/dto/response/WeatherResponseDto.kt @@ -1,10 +1,8 @@ package com.umcspot.spot.weather.dto.response import android.annotation.SuppressLint -import com.umcspot.spot.model.WeatherType import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.time.LocalTime @SuppressLint("UnsafeOptInUsageError") @Serializable @@ -20,7 +18,7 @@ data class WeatherResponse( val header: WeatherHeader, @SerialName("body") - val body: WeatherBody + val body: WeatherBody? = null ) @SuppressLint("UnsafeOptInUsageError") diff --git a/data/weather/src/main/java/com/umcspot/spot/weather/mapper/WeatherMapper.kt b/data/weather/src/main/java/com/umcspot/spot/weather/mapper/WeatherMapper.kt index 57e629b2..2fd9b56f 100644 --- a/data/weather/src/main/java/com/umcspot/spot/weather/mapper/WeatherMapper.kt +++ b/data/weather/src/main/java/com/umcspot/spot/weather/mapper/WeatherMapper.kt @@ -7,7 +7,7 @@ import java.time.LocalTime import java.time.format.DateTimeFormatter fun WeatherResponseDto.toDomain(): WeatherResult { - val items = response.body.items.item + val items = response.body!!.items.item // 기준 시간 (HHmm) val time = items.firstOrNull()?.baseTime diff --git a/domain/study/src/main/java/com/umcspot/spot/study/model/StudyResult.kt b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyResult.kt index a23e174d..926b0039 100644 --- a/domain/study/src/main/java/com/umcspot/spot/study/model/StudyResult.kt +++ b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyResult.kt @@ -27,6 +27,7 @@ data class StudyResult( val currentMembers: Int = 0, val likeCount: Int = 0, val isLiked : Boolean, + val isOwner : Boolean, val hitCount: Int = 0, val profileImageUrl: ImageRef ) { @@ -51,6 +52,7 @@ data class StudyResult( currentMembers = mem, likeCount = 10 + index * 2, isLiked = index%2 == 0, + isOwner = index%2 == 1, hitCount = 150 + index * 20, profileImageUrl = ImageRef.Name("ic_study_default") ) 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 7fdf0571..60341c8e 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 @@ -45,4 +45,19 @@ interface StudyRepository { cursor: Long?, size: Int, ): Result + + suspend fun getParticipatingStudy( + cursor: Long?, + size: Int, + ): Result + + suspend fun getRecruitingStudy( + cursor: Long?, + size: Int, + ): Result + + suspend fun getWaitingStudy( + cursor: Long?, + size: Int, + ): 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 6e2ce2c3..4c9237bf 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,6 +24,12 @@ import com.umcspot.spot.home.navigation.homeGraph import com.umcspot.spot.jjim.navigation.jjimGraph import com.umcspot.spot.model.QuickMenuType import com.umcspot.spot.mypage.main.navigation.mypageGraph +import com.umcspot.spot.mypage.participating.navigation.navigateToParticipatingStudy +import com.umcspot.spot.mypage.participating.navigation.participatingGraph +import com.umcspot.spot.mypage.recruiting.navigation.myRecruitingStudyGraph +import com.umcspot.spot.mypage.recruiting.navigation.navigateToMyRecruitingStudy +import com.umcspot.spot.mypage.waiting.navigation.navigateToWaitingStudy +import com.umcspot.spot.mypage.waiting.navigation.waitingStudyGraph import com.umcspot.spot.signup.navigation.signupGraph import com.umcspot.spot.study.detail.navigation.navigateToStudyDetail import com.umcspot.spot.study.detail.navigation.studyDetailGraph @@ -107,13 +113,35 @@ fun MainNavHost( mypageGraph( contentPadding = contentPadding, - onParticipatingClick = { /*navigator.navigateToRecruitingStudy()*/ }, - onMyRecruitingClick = { /*navigator.navigateToRecruitingStudy()*/ }, - onMyAppliedClick = { /*navigator.navigateToRecruitingStudy()*/ }, + onParticipatingClick = { navigator.navController.navigateToParticipatingStudy() }, + onMyRecruitingClick = { navigator.navController.navigateToMyRecruitingStudy() }, + onMyAppliedClick = { navigator.navController.navigateToWaitingStudy() }, onEditInterestClick = { /*navigator.navigateToCheckList*/ }, onEditInterestLocationClick = { } ) + participatingGraph( + contentPadding = contentPadding, + onRegisterScrollToTop = onRegisterScrollToTop, + onStudyClick = { navigator.navigateToStudyDetail(it) }, + moveToRecruitingStudy = { navigator.navigateToRecruitingStudy() } + ) + + myRecruitingStudyGraph( + contentPadding = contentPadding, + onRegisterScrollToTop = onRegisterScrollToTop, + onStudyClick = { navigator.navigateToStudyDetail(it) }, + moveToMakeStudy = { }, + moveToCheckApplied = { } + ) + + waitingStudyGraph( + contentPadding = contentPadding, + onRegisterScrollToTop = onRegisterScrollToTop, + onStudyClick = { navigator.navigateToStudyDetail(it) }, + moveToRecruitingStudy = { navigator.navigateToRecruitingStudy() }, + ) + recruitingStudyGraph( contentPadding = contentPadding, onRegisterScrollToTop = onRegisterScrollToTop, 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 a704b2e7..1e6d7715 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 @@ -24,6 +24,9 @@ import com.umcspot.spot.jjim.navigation.JJim import com.umcspot.spot.jjim.navigation.navigateToJJim import com.umcspot.spot.mypage.main.navigation.MyPage import com.umcspot.spot.mypage.main.navigation.navigateToMyPage +import com.umcspot.spot.mypage.participating.navigation.ParticipatingStudy +import com.umcspot.spot.mypage.recruiting.navigation.MyRecruitingStudy +import com.umcspot.spot.mypage.waiting.navigation.WaitingStudy import com.umcspot.spot.signup.navigation.CheckList import com.umcspot.spot.signup.navigation.Landing import com.umcspot.spot.signup.navigation.Saving @@ -97,11 +100,13 @@ class MainNavigator( @Composable fun showBackTopBar(): Boolean = inAnyGraph(Alert::class, RecruitingFilter::class, PreferLocationFilter::class, - SignUp::class, CheckList::class, Posting::class, BoardList::class, JJim::class, MyPage::class) || inAnyGraphRoutes(POST_CONTENT_ROUTE) + SignUp::class, CheckList::class, Posting::class, BoardList::class, JJim::class, MyPage::class, + ParticipatingStudy::class, MyRecruitingStudy::class, WaitingStudy::class + ) || inAnyGraphRoutes(POST_CONTENT_ROUTE) @Composable fun showToTopFab(): Boolean = inAnyGraph(Alert::class, Recruiting::class, - PreferLocation::class, BoardList::class, JJim::class) + PreferLocation::class, BoardList::class, JJim::class, ParticipatingStudy::class, MyRecruitingStudy::class, WaitingStudy::class) @Composable fun showMultipleFab(): Boolean = inAnyGraph(Home::class, BoardList::class) 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 a50b80ef..1d183e1e 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,9 @@ import com.umcspot.spot.home.navigation.Home import com.umcspot.spot.jjim.navigation.JJim import com.umcspot.spot.main.component.MainBottomBar import com.umcspot.spot.mypage.main.navigation.MyPage +import com.umcspot.spot.mypage.participating.navigation.ParticipatingStudy +import com.umcspot.spot.mypage.recruiting.navigation.MyRecruitingStudy +import com.umcspot.spot.mypage.waiting.navigation.WaitingStudy import com.umcspot.spot.signup.navigation.CheckList import com.umcspot.spot.signup.navigation.SignUp import com.umcspot.spot.study.detail.navigation.StudyDetail @@ -73,6 +76,9 @@ fun MainScreen( dest?.hasRoute(BoardList::class) == true -> "스터디 파트너들의 이야기" dest?.hasRoute(JJim::class) == true -> "찜한 스터디" dest?.hasRoute(MyPage::class) == true -> "마이페이지" + dest?.hasRoute(ParticipatingStudy::class) == true -> "참여 중인 스터디" + dest?.hasRoute(MyRecruitingStudy::class) == true -> "모집 중인 스터디" + dest?.hasRoute(WaitingStudy::class) == true -> "대기 중인 스터디" dest?.routeMatches(POST_CONTENT_ROUTE) == true -> "스터디 파트너들의 이야기" else -> "" } diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyScreenState.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageState.kt similarity index 100% rename from feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyScreenState.kt rename to feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageState.kt diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageViewModel.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageViewModel.kt index 911468aa..63efb1ff 100644 --- a/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageViewModel.kt +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/main/MyPageViewModel.kt @@ -1,4 +1,3 @@ -// HomeViewModel.kt (핵심만) package com.umcspot.spot.mypage.main import android.app.Application diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/ParticipatingStudyScreen.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/ParticipatingStudyScreen.kt new file mode 100644 index 00000000..bed134e9 --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/ParticipatingStudyScreen.kt @@ -0,0 +1,298 @@ +package com.umcspot.spot.mypage.participating + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +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.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.SpotSpinner +import com.umcspot.spot.designsystem.component.empty.EmptyAlertWithButton +import com.umcspot.spot.designsystem.component.study.StudyListItem +import com.umcspot.spot.designsystem.shapes.SpotShapes +import com.umcspot.spot.designsystem.theme.G300 +import com.umcspot.spot.designsystem.theme.R500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.model.StudyResult +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import com.umcspot.spot.ui.state.UiState +import kotlinx.coroutines.launch + +@Composable +fun ParticipatingScreen( + contentPadding : PaddingValues, + onRegisterScrollToTop: ((() -> Unit)?) -> Unit, + onStudyClick : (Long) -> Unit, + moveToRecruitingStudy : () -> Unit, + viewmodel : ParticipatingStudyViewModel = hiltViewModel() +) { + val uiState by viewmodel.uiState.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + + val ui = uiState.participatingStudy + val itemList: List = when (ui) { + is UiState.Success -> ui.data.studyList + else -> emptyList() + } + + LaunchedEffect(Unit) { + viewmodel.loadParticipatingStudy() + onRegisterScrollToTop { + scope.launch { + listState.animateScrollToItem(0) + } + } + } + + val shouldLoadMore = remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + totalItems > 0 && lastVisibleItemIndex >= totalItems - 3 + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value) { + val successData = (ui as? UiState.Success)?.data + if (successData?.hasNext == true) { + viewmodel.loadNextPage() + } + } + } + + when (ui) { + is UiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white), + contentAlignment = Alignment.Center + ) { + SpotSpinner(size = screenWidthDp(30.dp)) + } + } + is UiState.Empty, is UiState.Failure -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white), + contentAlignment = Alignment.Center + ) { + EmptyAlertWithButton( + alertTitle = "참여 중인 스터디가 아직 없어요!", + alertDes = "스팟에서 내 목표를 이뤄봐요", + buttonText = "스터디 둘러보기", + painter = painterResource(R.drawable.study_default), + onClick = { moveToRecruitingStudy() }, + ) + } + } + is UiState.Success -> { + ParticipatingStudyScreenContent( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding()) + .padding(horizontal = screenWidthDp(17.dp)), + studyList = itemList, + listState = listState, + onStudyClick = onStudyClick, + onEditClick = {}, + onReportClick = {}, + onLeaveClick = {} + ) + } + } +} + +@Composable +fun ParticipatingStudyScreenContent( + modifier: Modifier = Modifier, + studyList: List, + listState: LazyListState, + onStudyClick: (Long) -> Unit, + onEditClick: (Long) -> Unit, + onReportClick: (Long) -> Unit, + onLeaveClick: (Long) -> Unit +) { + var expandedForId by remember { mutableStateOf(null) } + + LazyColumn( + state = listState, + modifier = modifier, + ) { + items( + items = studyList, + key = { it.id } + ) { item -> + Spacer(Modifier.height(screenHeightDp(5.dp))) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopStart + ) { + StudyListItem( + item = item, + modifier = Modifier.fillMaxWidth(), + onClick = { onStudyClick(item.id) }, + meetballSlot = { + Box { + Box( + modifier = Modifier + .padding(top = screenHeightDp(4.dp)) + .width(screenWidthDp(24.dp)) + .height(screenWidthDp(22.dp)) + .clip(SpotShapes.Hard) + .clickable { expandedForId = item.id }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.meetball), + contentDescription = null, + modifier = Modifier.size(screenWidthDp(14.dp)) + ) + } + + MeetballMenu( + isOwner = item.isOwner , + expanded = expandedForId == item.id, + onDismiss = { expandedForId = null }, + onEdit = { expandedForId = null; onEditClick(item.id) }, + onReport = { expandedForId = null; onReportClick(item.id) }, + onLeave = { expandedForId = null; onLeaveClick(item.id) }, + ) + } + } + ) + } + + if (studyList.indexOf(item) != studyList.lastIndex) { + Spacer(Modifier.height(screenHeightDp(5.dp))) + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth(), + color = SpotTheme.colors.G300, + thickness = 1.dp + ) + } + } + } +} + +@Composable +fun MeetballMenu( + isOwner: Boolean, + expanded: Boolean, + onDismiss: () -> Unit, + onEdit: () -> Unit, + onReport: () -> Unit, + onLeave: () -> Unit +) { + DropdownMenu( + modifier = Modifier + .background(SpotTheme.colors.white), + shape = SpotShapes.Soft, + expanded = expanded, + onDismissRequest = onDismiss, + ) { + if (isOwner) { + DropdownMenuItem( + modifier = Modifier + .height(screenHeightDp(30.dp)) + .wrapContentWidth(), + text = { + Text( + text = "정보 수정하기", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black + ) + }, + onClick = { + onDismiss() + onEdit() + } + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = SpotTheme.colors.gray200 + ) + } + DropdownMenuItem( + modifier = Modifier + .height(screenHeightDp(30.dp)) + .wrapContentWidth(), + text = { + Text( + text = "스터디원 신고", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black + ) + }, + onClick = { + onDismiss() + onReport() + } + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = SpotTheme.colors.gray200 + ) + DropdownMenuItem( + modifier = Modifier + .height(screenHeightDp(30.dp)) + .wrapContentWidth(), + text = { + Text( + text = "스터디 나가기", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.R500 + ) + }, + onClick = { + onDismiss() + onLeave() + } + ) + } +} \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/ParticipatingStudyState.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/ParticipatingStudyState.kt new file mode 100644 index 00000000..9933ef86 --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/ParticipatingStudyState.kt @@ -0,0 +1,8 @@ +package com.umcspot.spot.mypage.participating + +import com.umcspot.spot.study.model.StudyResultList +import com.umcspot.spot.ui.state.UiState + +data class ParticipatingStudyState ( + val participatingStudy : UiState = UiState.Empty, +) \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/ParticipatingStudyViewModel.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/ParticipatingStudyViewModel.kt new file mode 100644 index 00000000..270e4f5b --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/ParticipatingStudyViewModel.kt @@ -0,0 +1,84 @@ +package com.umcspot.spot.mypage.participating + +import android.app.Application +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.study.repository.StudyRepository +import com.umcspot.spot.ui.state.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ParticipatingStudyViewModel @Inject constructor( + private val studyRepository: StudyRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(ParticipatingStudyState()) + val uiState: StateFlow = _uiState + + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore: StateFlow = _isLoadingMore.asStateFlow() + + fun loadParticipatingStudy() { + _uiState.update { it.copy(participatingStudy = UiState.Loading) } + + viewModelScope.launch { + studyRepository.getParticipatingStudy( + cursor = null, + size = 10 + ) + .onSuccess { info -> + _uiState.update { + it.copy( + participatingStudy = if (info.studyList.isEmpty()) { + UiState.Empty + } else { + UiState.Success(info) + } + ) + } + } + .onFailure { e -> + Log.e("ParticipatingStudyViewModel", "loadParticipatingStudy error", e) +// _uiState.update { it.copy(weatherInfo = UiState.Failure(e.message ?: "날씨 불러오기 실패")) } + } + } + } + + fun loadNextPage() { + val currentUi = _uiState.value.participatingStudy + val success = currentUi as? UiState.Success ?: return + val currentList = success.data + + if (!currentList.hasNext) return + if (_isLoadingMore.value) return + + viewModelScope.launch { + _isLoadingMore.value = true + runCatching { + studyRepository.getParticipatingStudy( + cursor = currentList.nextCursor, + size = 10 + ).getOrThrow() + }.onSuccess { newPage -> + val merged = currentList.copy( + studyList = currentList.studyList + newPage.studyList, + hasNext = newPage.hasNext, + nextCursor = newPage.nextCursor + ) + _uiState.update { it.copy(participatingStudy = UiState.Success(merged)) } + }.onFailure { e -> + Log.e("ParticipatingStudyViewModel", "loadNextpageError", e) + } + _isLoadingMore.value = false + } + } +} + diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/navigation/ParticipatingStudyNavigation.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/navigation/ParticipatingStudyNavigation.kt new file mode 100644 index 00000000..339c884a --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/participating/navigation/ParticipatingStudyNavigation.kt @@ -0,0 +1,33 @@ +package com.umcspot.spot.mypage.participating.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 com.umcspot.spot.mypage.participating.ParticipatingScreen +import com.umcspot.spot.navigation.Route +import kotlinx.serialization.Serializable + +fun NavController.navigateToParticipatingStudy(navOptions: NavOptions? = null) { + navigate(ParticipatingStudy, navOptions) +} + +fun NavGraphBuilder.participatingGraph( + contentPadding : PaddingValues, + onRegisterScrollToTop: ((() -> Unit)?) -> Unit, + onStudyClick : (Long) -> Unit, + moveToRecruitingStudy : () -> Unit +) { + composable { + ParticipatingScreen( + contentPadding = contentPadding, + onRegisterScrollToTop = onRegisterScrollToTop, + onStudyClick = onStudyClick, + moveToRecruitingStudy = moveToRecruitingStudy + ) + } +} + +@Serializable +data object ParticipatingStudy : Route \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/RecruitingStudyScreen.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/RecruitingStudyScreen.kt new file mode 100644 index 00000000..cad67e23 --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/RecruitingStudyScreen.kt @@ -0,0 +1,189 @@ +package com.umcspot.spot.mypage.recruiting + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.SpotSpinner +import com.umcspot.spot.designsystem.component.button.TextButton +import com.umcspot.spot.designsystem.component.button.TextButtonState +import com.umcspot.spot.designsystem.component.empty.EmptyAlertWithButton +import com.umcspot.spot.designsystem.component.study.StudyListItem +import com.umcspot.spot.designsystem.shapes.SpotShapes +import com.umcspot.spot.designsystem.theme.G300 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.model.StudyResult +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import com.umcspot.spot.ui.state.UiState +import kotlinx.coroutines.launch + +@Composable +fun RecruitingStudyScreen( + contentPadding : PaddingValues, + onRegisterScrollToTop: ((() -> Unit)?) -> Unit, + onStudyClick : (Long) -> Unit, + moveToMakeStudy : () -> Unit, + moveToCheckApplied: () -> Unit, + viewmodel : RecruitingStudyViewModel = hiltViewModel() +) { + val uiState by viewmodel.uiState.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + + val ui = uiState.recruitingStudy + val itemList: List = when (ui) { + is UiState.Success -> ui.data.studyList + else -> emptyList() + } + + LaunchedEffect(Unit) { + viewmodel.loadRecruitingStudy() + onRegisterScrollToTop { + scope.launch { + listState.animateScrollToItem(0) + } + } + } + + val shouldLoadMore = remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + totalItems > 0 && lastVisibleItemIndex >= totalItems - 3 + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value) { + val successData = (ui as? UiState.Success)?.data + if (successData?.hasNext == true) { + viewmodel.loadNextPage() + } + } + } + + when (ui) { + is UiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white), + contentAlignment = Alignment.Center + ) { + SpotSpinner(size = screenWidthDp(30.dp)) + } + } + is UiState.Empty, is UiState.Failure -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white), + contentAlignment = Alignment.Center + ) { + EmptyAlertWithButton( + alertTitle = "모집 중인 스터디가 아직 없어요!", + alertDes = "스터디 파트너들과 함께 목표를 이뤄보세요!", + buttonText = "스터디 만들기", + painter = painterResource(R.drawable.study_default), + onClick = { moveToMakeStudy() }, + ) + } + } + is UiState.Success -> { + RecruitingStudyScreenContent( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding()) + .padding(horizontal = screenWidthDp(17.dp)), + studyList = itemList, + listState = listState, + onStudyClick = onStudyClick, + moveToCheckApplied = moveToCheckApplied + ) + } + } +} + +@Composable +fun RecruitingStudyScreenContent( + modifier: Modifier = Modifier, + studyList: List, + listState: LazyListState, + onStudyClick: (Long) -> Unit, + moveToCheckApplied: () -> Unit +) { + LazyColumn( + state = listState, + modifier = modifier, + ) { + items( + items = studyList, + key = { it.id } + ) { item -> + Spacer(Modifier.height(screenHeightDp(5.dp))) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopStart + ) { + StudyListItem( + item = item, + modifier = Modifier.fillMaxWidth(), + onClick = { onStudyClick(item.id) }, + checkAppliedSlot = { + TextButton( + modifier = Modifier + .width(screenWidthDp(60.dp)) + .height(screenHeightDp(26.dp)), + text = "신청 확인", + style = SpotTheme.typography.regular_500, + onClick = moveToCheckApplied, + state = TextButtonState.B500State, + shape = SpotShapes.Hard + ) + } + ) + } + + if (studyList.indexOf(item) != studyList.lastIndex) { + Spacer(Modifier.height(screenHeightDp(5.dp))) + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth(), + color = SpotTheme.colors.G300, + thickness = 1.dp + ) + } + } + } +} diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/RecruitingStudyState.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/RecruitingStudyState.kt new file mode 100644 index 00000000..af87637f --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/RecruitingStudyState.kt @@ -0,0 +1,8 @@ +package com.umcspot.spot.mypage.recruiting + +import com.umcspot.spot.study.model.StudyResultList +import com.umcspot.spot.ui.state.UiState + +data class RecruitingStudyState ( + val recruitingStudy : UiState = UiState.Empty, +) \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/RecruitingStudyViewModel.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/RecruitingStudyViewModel.kt new file mode 100644 index 00000000..f9871afe --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/RecruitingStudyViewModel.kt @@ -0,0 +1,81 @@ +package com.umcspot.spot.mypage.recruiting + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.study.repository.StudyRepository +import com.umcspot.spot.ui.state.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RecruitingStudyViewModel @Inject constructor( + private val studyRepository: StudyRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(RecruitingStudyState()) + val uiState: StateFlow = _uiState + + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore: StateFlow = _isLoadingMore.asStateFlow() + + fun loadRecruitingStudy() { + _uiState.update { it.copy(recruitingStudy = UiState.Loading) } + + viewModelScope.launch { + studyRepository.getRecruitingStudy( + cursor = null, + size = 10 + ) + .onSuccess { info -> + _uiState.update { + it.copy( + recruitingStudy = if (info.studyList.isEmpty()) { + UiState.Empty + } else { + UiState.Success(info) + } + ) + } + } + .onFailure { e -> + Log.e("RecruitingStudyViewModel", "loadRecruitingStudy error", e) +// _uiState.update { it.copy(weatherInfo = UiState.Failure(e.message ?: "날씨 불러오기 실패")) } + } + } + } + + fun loadNextPage() { + val currentUi = _uiState.value.recruitingStudy + val success = currentUi as? UiState.Success ?: return + val currentList = success.data + + if (!currentList.hasNext) return + if (_isLoadingMore.value) return + + viewModelScope.launch { + _isLoadingMore.value = true + runCatching { + studyRepository.getRecruitingStudy( + cursor = currentList.nextCursor, + size = 10 + ).getOrThrow() + }.onSuccess { newPage -> + val merged = currentList.copy( + studyList = currentList.studyList + newPage.studyList, + hasNext = newPage.hasNext, + nextCursor = newPage.nextCursor + ) + _uiState.update { it.copy(recruitingStudy = UiState.Success(merged)) } + }.onFailure { e -> + Log.e("RecruitingStudyViewModel", "loadNextpageError", e) + } + _isLoadingMore.value = false + } + } +} + diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/navigation/RecruitingStudyNavigation.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/navigation/RecruitingStudyNavigation.kt new file mode 100644 index 00000000..bbd86160 --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/recruiting/navigation/RecruitingStudyNavigation.kt @@ -0,0 +1,35 @@ +package com.umcspot.spot.mypage.recruiting.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 com.umcspot.spot.mypage.recruiting.RecruitingStudyScreen +import com.umcspot.spot.navigation.Route +import kotlinx.serialization.Serializable + +fun NavController.navigateToMyRecruitingStudy(navOptions: NavOptions? = null) { + navigate(MyRecruitingStudy, navOptions) +} + +fun NavGraphBuilder.myRecruitingStudyGraph( + contentPadding : PaddingValues, + onRegisterScrollToTop: ((() -> Unit)?) -> Unit, + onStudyClick : (Long) -> Unit, + moveToMakeStudy :() -> Unit, + moveToCheckApplied: () -> Unit +) { + composable { + RecruitingStudyScreen( + contentPadding = contentPadding, + onRegisterScrollToTop = onRegisterScrollToTop, + onStudyClick = onStudyClick, + moveToMakeStudy = moveToMakeStudy, + moveToCheckApplied = moveToCheckApplied + ) + } +} + +@Serializable +data object MyRecruitingStudy : Route \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/WaitingStudyScreen.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/WaitingStudyScreen.kt new file mode 100644 index 00000000..6a081939 --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/WaitingStudyScreen.kt @@ -0,0 +1,175 @@ +package com.umcspot.spot.mypage.waiting + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.SpotSpinner +import com.umcspot.spot.designsystem.component.button.TextButton +import com.umcspot.spot.designsystem.component.button.TextButtonState +import com.umcspot.spot.designsystem.component.empty.EmptyAlert +import com.umcspot.spot.designsystem.component.empty.EmptyAlertWithButton +import com.umcspot.spot.designsystem.component.study.StudyListItem +import com.umcspot.spot.designsystem.shapes.SpotShapes +import com.umcspot.spot.designsystem.theme.G300 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.model.StudyResult +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import com.umcspot.spot.ui.state.UiState +import kotlinx.coroutines.launch + +@Composable +fun WaitingStudyScreen( + contentPadding : PaddingValues, + onRegisterScrollToTop: ((() -> Unit)?) -> Unit, + onStudyClick : (Long) -> Unit, + moveToRecruitingStudy : () -> Unit, + viewmodel : WaitingStudyViewModel = hiltViewModel() +) { + val uiState by viewmodel.uiState.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + + val ui = uiState.waitingStudy + val itemList: List = when (ui) { + is UiState.Success -> ui.data.studyList + else -> emptyList() + } + + LaunchedEffect(Unit) { + viewmodel.loadWaitingStudy() + onRegisterScrollToTop { + scope.launch { + listState.animateScrollToItem(0) + } + } + } + + val shouldLoadMore = remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + totalItems > 0 && lastVisibleItemIndex >= totalItems - 3 + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value) { + val successData = (ui as? UiState.Success)?.data + if (successData?.hasNext == true) { + viewmodel.loadNextPage() + } + } + } + + when (ui) { + is UiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white), + contentAlignment = Alignment.Center + ) { + SpotSpinner(size = screenWidthDp(30.dp)) + } + } + is UiState.Empty, is UiState.Failure -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white), + contentAlignment = Alignment.Center + ) { + EmptyAlertWithButton( + alertTitle = "신청한 스터디가 아직 없어요!", + alertDes = "스터디 파트너들과 함께 목표를 이뤄보세요!", + buttonText = "스터디 둘러보기", + painter = painterResource(R.drawable.study_default), + onClick = { moveToRecruitingStudy() }, + ) + } + } + is UiState.Success -> { + WaitingStudyScreenContent( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding()) + .padding(horizontal = screenWidthDp(17.dp)), + studyList = itemList, + listState = listState, + onStudyClick = onStudyClick, + ) + } + } +} + +@Composable +fun WaitingStudyScreenContent( + modifier: Modifier = Modifier, + studyList: List, + listState: LazyListState, + onStudyClick: (Long) -> Unit, +) { + LazyColumn( + state = listState, + modifier = modifier, + ) { + items( + items = studyList, + key = { it.id } + ) { item -> + Spacer(Modifier.height(screenHeightDp(5.dp))) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopStart + ) { + StudyListItem( + item = item, + modifier = Modifier.fillMaxWidth(), + onClick = { onStudyClick(item.id) } + ) + } + + if (studyList.indexOf(item) != studyList.lastIndex) { + Spacer(Modifier.height(screenHeightDp(5.dp))) + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth(), + color = SpotTheme.colors.G300, + thickness = 1.dp + ) + } + } + } +} diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/WaitingStudyState.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/WaitingStudyState.kt new file mode 100644 index 00000000..1843371b --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/WaitingStudyState.kt @@ -0,0 +1,8 @@ +package com.umcspot.spot.mypage.waiting + +import com.umcspot.spot.study.model.StudyResultList +import com.umcspot.spot.ui.state.UiState + +data class WaitingStudyState ( + val waitingStudy : UiState = UiState.Empty, +) \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/WaitingStudyViewModel.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/WaitingStudyViewModel.kt new file mode 100644 index 00000000..571fd0f8 --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/WaitingStudyViewModel.kt @@ -0,0 +1,81 @@ +package com.umcspot.spot.mypage.waiting + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.study.repository.StudyRepository +import com.umcspot.spot.ui.state.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WaitingStudyViewModel @Inject constructor( + private val studyRepository: StudyRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(WaitingStudyState()) + val uiState: StateFlow = _uiState + + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore: StateFlow = _isLoadingMore.asStateFlow() + + fun loadWaitingStudy() { + _uiState.update { it.copy(waitingStudy = UiState.Loading) } + + viewModelScope.launch { + studyRepository.getWaitingStudy( + cursor = null, + size = 10 + ) + .onSuccess { info -> + _uiState.update { + it.copy( + waitingStudy = if (info.studyList.isEmpty()) { + UiState.Empty + } else { + UiState.Success(info) + } + ) + } + } + .onFailure { e -> + Log.e("WaitingStudyViewModel", "loadWaiting error", e) +// _uiState.update { it.copy(weatherInfo = UiState.Failure(e.message ?: "날씨 불러오기 실패")) } + } + } + } + + fun loadNextPage() { + val currentUi = _uiState.value.waitingStudy + val success = currentUi as? UiState.Success ?: return + val currentList = success.data + + if (!currentList.hasNext) return + if (_isLoadingMore.value) return + + viewModelScope.launch { + _isLoadingMore.value = true + runCatching { + studyRepository.getWaitingStudy( + cursor = currentList.nextCursor, + size = 10 + ).getOrThrow() + }.onSuccess { newPage -> + val merged = currentList.copy( + studyList = currentList.studyList + newPage.studyList, + hasNext = newPage.hasNext, + nextCursor = newPage.nextCursor + ) + _uiState.update { it.copy(waitingStudy = UiState.Success(merged)) } + }.onFailure { e -> + Log.e("WaitingStudyViewModel", "loadNextpageError", e) + } + _isLoadingMore.value = false + } + } +} + diff --git a/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/navigation/WaitingStudyNavigation.kt b/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/navigation/WaitingStudyNavigation.kt new file mode 100644 index 00000000..2d8ed750 --- /dev/null +++ b/feature/mypage/src/main/java/com/umcspot/spot/mypage/waiting/navigation/WaitingStudyNavigation.kt @@ -0,0 +1,33 @@ +package com.umcspot.spot.mypage.waiting.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 com.umcspot.spot.mypage.waiting.WaitingStudyScreen +import com.umcspot.spot.navigation.Route +import kotlinx.serialization.Serializable + +fun NavController.navigateToWaitingStudy(navOptions: NavOptions? = null) { + navigate(WaitingStudy, navOptions) +} + +fun NavGraphBuilder.waitingStudyGraph( + contentPadding : PaddingValues, + onRegisterScrollToTop: ((() -> Unit)?) -> Unit, + onStudyClick : (Long) -> Unit, + moveToRecruitingStudy :() -> Unit, +) { + composable { + WaitingStudyScreen( + contentPadding = contentPadding, + onRegisterScrollToTop = onRegisterScrollToTop, + onStudyClick = onStudyClick, + moveToRecruitingStudy = moveToRecruitingStudy, + ) + } +} + +@Serializable +data object WaitingStudy : Route \ No newline at end of file