diff --git a/.github/ISSUE_TEMPLATE/issue-form.yml b/.github/ISSUE_TEMPLATE/issue-form.yml index 0352a42ab..48bf08c60 100644 --- a/.github/ISSUE_TEMPLATE/issue-form.yml +++ b/.github/ISSUE_TEMPLATE/issue-form.yml @@ -1,16 +1,7 @@ name: '이슈 생성' -description: '이슈 생성과 동시에 Jira와 연동됩니다.' +description: '새 이슈를 생성합니다' title: '이슈 제목' body: - - type: input - id: epicId - attributes: - label: '💎 에픽 ID' - description: '에픽 ID를 입력해주세요' - placeholder: 'KAN-1' - validations: - required: true - - type: input id: dueDate attributes: diff --git a/README.md b/README.md index 15c33757e..0c718480a 100644 --- a/README.md +++ b/README.md @@ -3,42 +3,53 @@

- +

+ PlayStore · + About us · + Previous Repository +

No More Research

Acon

image

-

- -     - -     - -

+ + + + + + + + + + + + + +
리뷰 등록북마크 및 장소상세장소 업로드
--- ## ⚙️ Teck Stacks -| 분류 | 사용 기술 | -|------|------------| -| Language | Kotlin | -| UI | Jetpack Compose, Material 3 | +| 분류 | 사용 기술 | +|------|---------------------------------------------| +| Language | Kotlin | +| UI | Jetpack Compose, Material 3 | | Architecture | MVI Orbit, Multi-module, Clean Architecture | -| DI | Hilt | -| Network | Retrofit, OkHttp | -| CI/CD | GitHub Actions, Firebase App Distribution | -| Etc | Play-services, Amplitude, Haze | +| DI | Hilt | +| Network | Retrofit, OkHttp | +| CI/CD | GitHub Actions, Firebase App Distribution | +| Etc | Play-services, Amplitude, Haze, Branch.io | --- ## 🙌 Contributors -[@Thirfir](https://github.com/ThirFir) +[@Thirfir](https://github.com/ThirFir) (2025.1 ~ )
-[@1971123-seongmin](https://github.com/1971123-seongmin) +[@1971123-seongmin](https://github.com/1971123-seongmin) (2025.1 ~ )
-[@tunaunnie](https://github.com/tunaunnie) +[@tunaunnie](https://github.com/tunaunnie) (2025.1 ~ 2025.2)
-[@0se0](https://github.com/0se0) +[@0se0](https://github.com/0se0) (2025.1 ~ 2025.2) diff --git a/core/designsystem/src/main/java/com/acon/acon/core/designsystem/animation/SlideUpAnimation.kt b/core/designsystem/src/main/java/com/acon/acon/core/designsystem/animation/SlideUpAnimation.kt new file mode 100644 index 000000000..affc1e900 --- /dev/null +++ b/core/designsystem/src/main/java/com/acon/acon/core/designsystem/animation/SlideUpAnimation.kt @@ -0,0 +1,86 @@ +package com.acon.acon.core.designsystem.animation + +import android.annotation.SuppressLint +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.acon.acon.core.designsystem.R +import kotlinx.coroutines.delay + +@SuppressLint("UseOfNonLambdaOffsetOverload") +@Composable +fun Modifier.slideUpAnimation( + order: Int = 1, + delay: Int = 0, + duration: Int = 500, + easing: Easing = LinearOutSlowInEasing, + yOffset: Dp = 100.dp, + titleDelay: Int = 300, + contentDelay: Int = 600, + hasCaption: Boolean = false, + onAnimationEnded: () -> Unit = {} +): Modifier = composed { + val animationDelay = remember(order, hasCaption) { + when (order) { + 1 -> delay + 2 -> delay + titleDelay + 3 -> if (hasCaption) delay + titleDelay * 2 else 0 + 4 -> if (hasCaption) { + delay + titleDelay * 2 + contentDelay + } else { + delay + titleDelay + contentDelay + } + else -> delay + } + } + + var visible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + delay(animationDelay.toLong()) + visible = true + } + + val offsetY by animateDpAsState( + targetValue = if (visible) 0.dp else yOffset, + animationSpec = tween( + durationMillis = duration, + easing = easing + ), + label = stringResource(R.string.slide_up_offset) + ) + + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = tween( + durationMillis = duration, + easing = easing + ), + label = stringResource(R.string.slide_up_alpha) + ) + + LaunchedEffect(offsetY) { + if (offsetY == 0.dp) { + onAnimationEnded() + } + } + + Modifier + .offset(y = offsetY) + .alpha(alpha) +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/dialog/v2/AconTwoActionDialog.kt b/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/dialog/v2/AconTwoActionDialog.kt index 3f1e8350d..67d033250 100644 --- a/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/dialog/v2/AconTwoActionDialog.kt +++ b/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/dialog/v2/AconTwoActionDialog.kt @@ -34,6 +34,7 @@ fun AconTwoActionDialog( onAction1: () -> Unit, onAction2: () -> Unit, onDismissRequest: () -> Unit, + isTextAlign: Boolean = false, action1Color: Color = AconTheme.color.White, action2Color: Color = AconTheme.color.Action, modifier: Modifier = Modifier, @@ -65,6 +66,7 @@ fun AconTwoActionDialog( style = AconTheme.typography.Title4, fontWeight = FontWeight.SemiBold, color = AconTheme.color.White, + textAlign = if (isTextAlign) TextAlign.Center else TextAlign.Unspecified, modifier = Modifier .padding(top = 24.dp, bottom = 22.dp) .padding(horizontal = 16.dp) diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index f2fc56751..d5b7284c4 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -12,6 +12,7 @@ 선택 제출하기 끝내기 + 그만두기 제보하기 계속 작성 다음 @@ -31,6 +32,10 @@ 다음에 하기 홈으로 가기 + + slide_up_offset + slide_up_alpha + 마무리 하기 업데이트 @@ -212,7 +217,7 @@ upload_process_step_transition - 등록을 취소하시겠습니까? + 여기서 그만두면\n작성중인 내용이 사라져요 장소 등록 필수 입력 선택 입력 diff --git a/core/model/src/main/java/com/acon/acon/core/model/type/FeatureType.kt b/core/model/src/main/java/com/acon/acon/core/model/type/FeatureType.kt index bd553a6f1..228bead6f 100644 --- a/core/model/src/main/java/com/acon/acon/core/model/type/FeatureType.kt +++ b/core/model/src/main/java/com/acon/acon/core/model/type/FeatureType.kt @@ -29,7 +29,7 @@ sealed interface RestaurantFeatureType : FeatureType { sealed interface CafeFeatureType : FeatureType { enum class CafeType: FeatureType { - GOOD_FOR_WORK, + WORK_FRIENDLY, NOT_GOOD_FOR_WORK; } } diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/SpotNoAuthApi.kt b/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/SpotNoAuthApi.kt index 24e7b997b..0e1125e65 100644 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/SpotNoAuthApi.kt +++ b/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/SpotNoAuthApi.kt @@ -1,8 +1,10 @@ package com.acon.acon.data.api.remote.noauth import com.acon.acon.data.dto.request.RecentNavigationLocationRequest +import com.acon.acon.data.dto.request.SpotListRequest import com.acon.acon.data.dto.response.MenuBoardListResponse import com.acon.acon.data.dto.response.SpotDetailResponse +import com.acon.acon.data.dto.response.SpotListResponse import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -11,6 +13,11 @@ import retrofit2.http.Query interface SpotNoAuthApi { + @POST("/api/v1/spots") + suspend fun fetchSpotList( + @Body request: SpotListRequest + ): SpotListResponse + @POST("/api/v1/guided-spots") suspend fun fetchRecentNavigationLocation( @Body request: RecentNavigationLocationRequest diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/SpotRemoteDataSource.kt b/data/src/main/kotlin/com/acon/acon/data/datasource/remote/SpotRemoteDataSource.kt index d79bc6311..8249b70d1 100644 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/SpotRemoteDataSource.kt +++ b/data/src/main/kotlin/com/acon/acon/data/datasource/remote/SpotRemoteDataSource.kt @@ -1,5 +1,6 @@ package com.acon.acon.data.datasource.remote +import com.acon.acon.core.model.type.UserType import com.acon.acon.data.api.remote.auth.SpotAuthApi import com.acon.acon.data.api.remote.noauth.SpotNoAuthApi import com.acon.acon.data.dto.request.AddBookmarkRequest @@ -16,8 +17,10 @@ class SpotRemoteDataSource @Inject constructor( private val spotAuthApi: SpotAuthApi ) { - suspend fun fetchSpotList(request: SpotListRequest): SpotListResponse { - return spotAuthApi.fetchSpotList(request) + suspend fun fetchSpotList(request: SpotListRequest, userType: UserType): SpotListResponse { + return if (userType == UserType.GUEST) + spotNoAuthApi.fetchSpotList(request) + else spotAuthApi.fetchSpotList(request) } suspend fun fetchRecentNavigationLocation(request: RecentNavigationLocationRequest) { diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/SpotRepositoryImpl.kt b/data/src/main/kotlin/com/acon/acon/data/repository/SpotRepositoryImpl.kt index 306e3ad47..3ee4843cc 100644 --- a/data/src/main/kotlin/com/acon/acon/data/repository/SpotRepositoryImpl.kt +++ b/data/src/main/kotlin/com/acon/acon/data/repository/SpotRepositoryImpl.kt @@ -13,6 +13,7 @@ import com.acon.acon.data.dto.request.FilterListRequest import com.acon.acon.data.dto.request.RecentNavigationLocationRequest import com.acon.acon.data.dto.request.SpotListRequest import com.acon.acon.data.error.runCatchingWith +import com.acon.acon.data.session.SessionHandler import com.acon.acon.domain.error.spot.AddBookmarkError import com.acon.acon.domain.error.spot.DeleteBookmarkError import com.acon.acon.domain.error.spot.FetchMenuBoardsError @@ -21,12 +22,14 @@ import com.acon.acon.domain.error.spot.FetchSpotListError import com.acon.acon.domain.error.spot.GetSpotDetailInfoError import com.acon.acon.domain.repository.ProfileRepository import com.acon.acon.domain.repository.SpotRepository +import kotlinx.coroutines.flow.first import javax.inject.Inject class SpotRepositoryImpl @Inject constructor( private val spotRemoteDataSource: SpotRemoteDataSource, private val profileInfoCache: ProfileInfoCache, - private val profileRepository: ProfileRepository + private val profileRepository: ProfileRepository, + private val sessionHandler: SessionHandler ) : SpotRepository { override suspend fun fetchSpotList( @@ -48,7 +51,7 @@ class SpotRepositoryImpl @Inject constructor( ) } ), - ) + ), sessionHandler.getUserType().first() ).toSpotList() } } diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/UploadPlaceViewModel.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/UploadPlaceViewModel.kt index e3e865e43..e728bdd76 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/UploadPlaceViewModel.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/UploadPlaceViewModel.kt @@ -16,6 +16,7 @@ import com.acon.acon.domain.repository.MapSearchRepository import com.acon.acon.domain.repository.UploadRepository import com.acon.acon.feature.upload.BuildConfig import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -25,6 +26,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.ConnectionPool import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request @@ -33,6 +36,7 @@ import okhttp3.internal.toImmutableList import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container import timber.log.Timber +import java.util.concurrent.TimeUnit import javax.inject.Inject @OptIn(FlowPreview::class) @@ -150,11 +154,7 @@ class UploadPlaceViewModel @Inject constructor( } fun updateCafeOptionType(cafeOption: CafeFeatureType.CafeType) = intent { - if(cafeOption == CafeFeatureType.CafeType.NOT_GOOD_FOR_WORK) { - reduce { state.copy(selectedCafeOption = null) } - } else { - reduce { state.copy(selectedCafeOption = cafeOption) } - } + reduce { state.copy(selectedFeature = SelectedFeature.Cafe(cafeOption)) } } fun updatePriceOptionType(priceOption: PriceFeatureType.PriceOptionType) = intent { @@ -163,14 +163,14 @@ class UploadPlaceViewModel @Inject constructor( fun updateRestaurantType(type: RestaurantFeatureType.RestaurantType) = intent { reduce { - val currentSelectedTypes = state.selectedRestaurantTypes.toMutableList() + val currentSelectedTypes = (state.selectedFeature as? SelectedFeature.Restaurant)?.types?.toMutableList() ?: mutableListOf() if (currentSelectedTypes.contains(type)) { currentSelectedTypes.remove(type) } else { currentSelectedTypes.add(type) } - state.copy(selectedRestaurantTypes = currentSelectedTypes) + state.copy(selectedFeature = SelectedFeature.Restaurant(currentSelectedTypes)) } } @@ -270,6 +270,13 @@ class UploadPlaceViewModel @Inject constructor( } } + fun onSlideAnimationEnd(route: String) = intent { + reduce { + val updatedMap = state.hasAnimated.toMutableMap().apply { this[route] = true } + state.copy(hasAnimated = updatedMap) + } + } + fun onNavigateToBack() = intent { postSideEffect(UploadPlaceSideEffect.OnNavigateToBack) } @@ -281,27 +288,28 @@ class UploadPlaceViewModel @Inject constructor( private fun createFeatureList() = intent { val featureRequests = mutableListOf() - when(state.selectedRestaurantTypes.isEmpty()) { - true -> { - state.selectedCafeOption?.let { cafeOption -> + when (val feature = state.selectedFeature) { + is SelectedFeature.Cafe -> { + if (feature.option == CafeFeatureType.CafeType.WORK_FRIENDLY) { featureRequests.add( Feature( category = CategoryType.CAFE_FEATURE, - optionList = listOf(cafeOption) + optionList = listOf(feature.option) ) ) } } - false -> { + is SelectedFeature.Restaurant -> { featureRequests.add( Feature( category = CategoryType.RESTAURANT_FEATURE, - optionList = state.selectedRestaurantTypes.map { it } + optionList = feature.types ) ) } - } + null -> TODO() + } state.selectedPriceOption?.let { priceOption -> featureRequests.add( Feature( @@ -320,7 +328,7 @@ class UploadPlaceViewModel @Inject constructor( fun onSubmitUploadPlace(onSuccess:() -> Unit) = intent { createFeatureList() - + reduce { state.copy(isNextBtnEnabled = false) } when (state.selectedImageUris?.isEmpty()) { true -> { submitUploadPlace(onSuccess) @@ -338,7 +346,6 @@ class UploadPlaceViewModel @Inject constructor( onSuccess:() -> Unit, imageList: List = emptyList() ) = intent { - uploadRepository.submitUploadPlace( spotName = state.selectedSpotByMap?.title ?: "", address = state.selectedSpotByMap?.address ?: "", @@ -347,97 +354,118 @@ class UploadPlaceViewModel @Inject constructor( recommendedMenu = state.recommendMenu ?: "", imageList = imageList ).onSuccess { + reduce { state.copy(isNextBtnEnabled = true) } onSuccess() }.onFailure { + reduce { state.copy(isNextBtnEnabled = true) } postSideEffect(UploadPlaceSideEffect.ShowToastUploadFailed) } } private fun uploadAllImagesAndSubmit(onSuccess: () -> Unit) = intent { - val uris = state.selectedImageUris + val uris = state.selectedImageUris ?: emptyList() + + if (uris.isEmpty()) { + submitUploadPlace(onSuccess = onSuccess, imageList = emptyList()) + return@intent + } - val fileNames = try { + val presignedResults = runCatching { coroutineScope { - uris?.map { imageUri -> - async { - val presignedResult = try { - uploadRepository.getUploadPlacePreSignedUrl().getOrThrow() - } catch (e: Exception) { - postSideEffect(UploadPlaceSideEffect.ShowToastUploadImageFailed) - throw e - } - putPlaceImageToPreSignedUrl(imageUri, presignedResult.preSignedUrl) - presignedResult.fileName + (0 until uris.size).map { + async(Dispatchers.IO) { + uploadRepository.getUploadPlacePreSignedUrl().getOrThrow() } - }?.awaitAll() + }.awaitAll() } - } catch (e: Exception) { + }.onFailure { postSideEffect(UploadPlaceSideEffect.ShowToastUploadImageFailed) - null - } + return@intent + }.getOrThrow() - val bucketUrls = fileNames?.map { fileName -> - "${BuildConfig.BUCKET_URL}$fileName" + val uploadSuccessful = runCatching { + coroutineScope { + uris.zip(presignedResults).map { (imageUri, presignedResult) -> + async(Dispatchers.IO) { + putPlaceImageToPreSignedUrlOptimized(imageUri, presignedResult.preSignedUrl) + } + }.awaitAll().all { it } + } + }.onFailure { + postSideEffect(UploadPlaceSideEffect.ShowToastUploadImageFailed) + return@intent + }.getOrThrow() + + if (!uploadSuccessful) { + postSideEffect(UploadPlaceSideEffect.ShowToastUploadImageFailed) + return@intent } - submitUploadPlace( - onSuccess = onSuccess, - imageList = bucketUrls ?: emptyList() - ) + val bucketUrls = presignedResults.map { "${BuildConfig.BUCKET_URL}${it.fileName}" } + submitUploadPlace(onSuccess = onSuccess, imageList = bucketUrls) } - private fun putPlaceImageToPreSignedUrl( + private suspend fun putPlaceImageToPreSignedUrlOptimized( imageUri: Uri, preSignedUrl: String - ) = intent { + ): Boolean = withContext(Dispatchers.IO) { val context = getApplication().applicationContext - val client = OkHttpClient() - try { + return@withContext try { val byteArray: ByteArray val mimeType: String if (imageUri.scheme == "content") { - val inputStream = context.contentResolver.openInputStream(imageUri) - byteArray = inputStream?.readBytes() - ?: throw IllegalArgumentException("이미지 읽기 실패") + context.contentResolver.openInputStream(imageUri).use { inputStream -> + byteArray = inputStream?.readBytes() + ?: throw IllegalArgumentException("이미지 읽기 실패") + } mimeType = context.contentResolver.getType(imageUri) ?: "image/jpeg" - } else { Timber.tag(TAG).e("지원하지 않는 URI scheme: %s", imageUri.toString()) throw IllegalArgumentException("지원하지 않는 URI scheme") } - val fileBody = - byteArray.toRequestBody(mimeType.toMediaTypeOrNull(), 0, byteArray.size) - + val fileBody = byteArray.toRequestBody(mimeType.toMediaTypeOrNull(), 0, byteArray.size) val request = Request.Builder() .url(preSignedUrl) .put(fileBody) .addHeader("Content-Type", mimeType) .build() - val response = client.newCall(request).execute() - - if (response.isSuccessful) { - Timber.tag(TAG).d("이미지 업로드 성공") - } else { - Timber.tag(TAG).e("이미지 업로드 실패, code: %d", response.code) - postSideEffect(UploadPlaceSideEffect.ShowToastUploadImageFailed) + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + Timber.tag(TAG).d("이미지 업로드 성공") + true + } else { + Timber.tag(TAG).e("이미지 업로드 실패, code: ${response.code}") + false + } } } catch (e: Exception) { - Timber.tag(TAG).e(e, "이미지 업로드 과정에서 예외 발생: %s", e.message) - postSideEffect(UploadPlaceSideEffect.ShowToastUploadImageFailed) + Timber.tag(TAG).e(e, "이미지 업로드 과정에서 예외 발생: ${e.message}") + false } } companion object { + private val client: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectionPool(ConnectionPool(20, 5, TimeUnit.MINUTES)) + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + const val TAG = "UploadPlaceViewModel" } } @Immutable data class UploadPlaceUiState( + val hasAnimated: Map = emptyMap(), val isPreviousBtnEnabled: Boolean = false, val isNextBtnEnabled: Boolean = false, val showExitUploadPlaceDialog: Boolean = false, @@ -451,8 +479,7 @@ data class UploadPlaceUiState( val selectedSpotByMap: SearchedSpotByMap? = null, val selectedSpotType: SpotType? = null, val selectedPriceOption: PriceFeatureType.PriceOptionType? = null, - val selectedCafeOption: CafeFeatureType.CafeType? = null, - val selectedRestaurantTypes: List = emptyList(), + val selectedFeature: SelectedFeature? = null, val recommendMenu: String? = "", val selectedImageUris: List? = emptyList(), val uploadFileName: String = "", @@ -462,6 +489,11 @@ data class UploadPlaceUiState( val currentStep: Int = 0 ) +sealed class SelectedFeature { + data class Cafe(val option: CafeFeatureType.CafeType) : SelectedFeature() + data class Restaurant(val types: List) : SelectedFeature() +} + sealed interface UploadPlaceSideEffect { data object ShowToastUploadFailed : UploadPlaceSideEffect data object ShowToastUploadImageFailed : UploadPlaceSideEffect diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/UploadPlaceScreen.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/UploadPlaceScreen.kt index a4a6e30b6..eed7a1598 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/UploadPlaceScreen.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/UploadPlaceScreen.kt @@ -5,6 +5,7 @@ import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith @@ -58,6 +59,7 @@ import com.acon.acon.feature.upload.screen.composable.menu.UploadPlaceEnterMenuS import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect +private const val animationDuration = 500 private const val maxStepIndex = 6 @Composable @@ -75,7 +77,11 @@ fun UploadPlaceScreen( val currentStep = state.currentStep BackHandler { - viewModel.onRequestExitUploadPlaceDialog() + if(currentStep != maxStepIndex) { + viewModel.onRequestExitUploadPlaceDialog() + } else { + viewModel.onNavigateToBack() + } } viewModel.collectSideEffect { @@ -112,7 +118,7 @@ fun UploadPlaceScreen( AconTwoActionDialog( title = stringResource(R.string.upload_place_exit), action1 = stringResource(R.string.cancel), - action2 = stringResource(R.string.exit), + action2 = stringResource(R.string.quit), onDismissRequest = {}, onAction1 = { viewModel.onDismissExitUploadPlaceDialog() @@ -120,7 +126,9 @@ fun UploadPlaceScreen( onAction2 = { viewModel.onNavigateToBack() }, - modifier = Modifier.width(dialogWidth) + isTextAlign = true, + modifier = Modifier + .width(dialogWidth) ) } @@ -180,9 +188,23 @@ fun UploadPlaceScreen( targetState = currentStep, transitionSpec = { if (targetState > initialState) { - slideInVertically { height -> height } togetherWith slideOutVertically { height -> -height } + slideInVertically( + animationSpec = tween(durationMillis = animationDuration) + ) { height -> height } + .togetherWith( + slideOutVertically( + animationSpec = tween(durationMillis = animationDuration) + ) { height -> -height } + ) } else { - slideInVertically { height -> -height } togetherWith slideOutVertically { height -> height } + slideInVertically( + animationSpec = tween(durationMillis = animationDuration) + ) { height -> -height } + .togetherWith( + slideOutVertically(animationSpec = tween(durationMillis = animationDuration)) { + height -> height + } + ) }.using(SizeTransform(clip = true)) }, label = stringResource(R.string.upload_process_step_transition), @@ -196,28 +218,33 @@ fun UploadPlaceScreen( onClickReportPlace = viewModel::onClickReportPlace, onHideSearchedPlaceList = viewModel::onHideSearchedPlaceList, onSearchedSpotClick = viewModel::onSearchSpotByMapClicked, - onSearchQueryOrSelectionChanged = viewModel::onSearchQueryOrSelectionChanged + onSearchQueryOrSelectionChanged = viewModel::onSearchQueryOrSelectionChanged, + onAnimationEnded = viewModel::onSlideAnimationEnd ) 1 -> UploadSelectPlaceScreen( state = state, onSelectSpotType = viewModel::updateSpotType, - onUpdateNextPageBtnEnabled = viewModel::updateNextBtnEnabled + onUpdateNextPageBtnEnabled = viewModel::updateNextBtnEnabled, + onAnimationEnded = viewModel::onSlideAnimationEnd ) 2 -> UploadSelectPlaceDetailScreen( state = state, onUpdateCafeOptionType = viewModel::updateCafeOptionType, onUpdateRestaurantType = viewModel::updateRestaurantType, - onUpdateNextPageBtnEnabled = viewModel::updateNextBtnEnabled + onUpdateNextPageBtnEnabled = viewModel::updateNextBtnEnabled, + onAnimationEnded = viewModel::onSlideAnimationEnd ) 3 -> UploadPlaceEnterMenuScreen( state = state, onSearchQueryChanged = viewModel::onSearchQueryChanged, - onUpdateNextPageBtnEnabled = viewModel::updateNextBtnEnabled + onUpdateNextPageBtnEnabled = viewModel::updateNextBtnEnabled, + onAnimationEnded = viewModel::onSlideAnimationEnd ) 4 -> UploadSelectPriceScreen( state = state, onUpdatePriceOptionType = viewModel::updatePriceOptionType, - onUpdateNextPageBtnEnabled = viewModel::updateNextBtnEnabled + onUpdateNextPageBtnEnabled = viewModel::updateNextBtnEnabled, + onAnimationEnded = viewModel::onSlideAnimationEnd ) 5 -> UploadPlaceImageScreen( state = state, @@ -226,10 +253,11 @@ fun UploadPlaceScreen( onAddSpotImageUri = viewModel::onAddImageUris, onRemoveSpotImageUri = viewModel::onRemoveImageUri, onUpdateNextPageBtnEnabled = viewModel::updateNextBtnEnabled, - onRequestUploadPlaceLimitPouUp = viewModel::onRequestUploadPlaceLimitPouUp + onRequestUploadPlaceLimitPouUp = viewModel::onRequestUploadPlaceLimitPouUp, + onAnimationEnded = viewModel::onSlideAnimationEnd ) 6 -> UploadPlaceCompleteScreen( - onClickGoHome = viewModel::onNavigateToBack + onClickGoHome = viewModel::onNavigateToBack, ) } } diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/image/UploadPlaceImageScreen.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/image/UploadPlaceImageScreen.kt index 7250cbd92..a5d1d6717 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/image/UploadPlaceImageScreen.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/image/UploadPlaceImageScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.animation.slideUpAnimation import com.acon.acon.core.designsystem.component.dialog.v2.AconTwoActionDialog import com.acon.acon.core.designsystem.component.popup.CustomToast import com.acon.acon.core.designsystem.effect.LocalHazeState @@ -58,8 +59,10 @@ internal fun UploadPlaceImageScreen( onAddSpotImageUri:(uris: List) -> Unit, onRemoveSpotImageUri:(uri: Uri) -> Unit, onUpdateNextPageBtnEnabled: (Boolean) -> Unit, - onRequestUploadPlaceLimitPouUp: () -> Unit + onRequestUploadPlaceLimitPouUp: () -> Unit, + onAnimationEnded: (String) -> Unit ) { + val hasAnimated = state.hasAnimated["7"] ?: false val selectedUris = state.selectedImageUris ?: emptyList() val screenHeightDp = getScreenHeight() @@ -141,7 +144,9 @@ internal fun UploadPlaceImageScreen( text = stringResource(R.string.optional_field), style = AconTheme.typography.Body1, color = AconTheme.color.Gray300, - modifier = Modifier.padding(top = 40.dp) + modifier = Modifier + .padding(top = 40.dp) + .then(if (!hasAnimated) Modifier.slideUpAnimation(order = 1) else Modifier) ) Spacer(Modifier.height(4.dp)) @@ -149,16 +154,28 @@ internal fun UploadPlaceImageScreen( text = stringResource(R.string.upload_place_image_title), style = AconTheme.typography.Headline3, color = AconTheme.color.White, - modifier = Modifier.padding(2.dp) + modifier = Modifier + .padding(2.dp) + .then(if (!hasAnimated) Modifier.slideUpAnimation(order = 2) else Modifier) ) Spacer(Modifier.height(32.dp)) - Box { + Box( + modifier = Modifier + .fillMaxWidth() + .height(imageBoxHeight) + .clip(RoundedCornerShape(10.dp)) + .then( + if (!hasAnimated) Modifier.slideUpAnimation( + order = 4, + onAnimationEnded = { onAnimationEnded("7") } + ) else Modifier + ) + ) { if (selectedUris.isEmpty()) { Box( modifier = Modifier - .fillMaxWidth() - .height(imageBoxHeight) + .fillMaxSize() .clip(RoundedCornerShape(10.dp)) .background(AconTheme.color.GlassWhiteDisabled) .aspectRatio(1f) @@ -272,7 +289,8 @@ private fun UploadPlaceImageScreenPreview() { onAddSpotImageUri = {}, onRemoveSpotImageUri = {}, onUpdateNextPageBtnEnabled = {}, - onRequestUploadPlaceLimitPouUp = {} + onRequestUploadPlaceLimitPouUp = {}, + onAnimationEnded = {} ) } } \ No newline at end of file diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/place/UploadSelectPlaceDetailScreen.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/place/UploadSelectPlaceDetailScreen.kt index cb6be0c8d..20cbdda37 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/place/UploadSelectPlaceDetailScreen.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/place/UploadSelectPlaceDetailScreen.kt @@ -22,10 +22,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.animation.slideUpAnimation import com.acon.acon.core.designsystem.theme.AconTheme import com.acon.acon.core.model.type.CafeFeatureType import com.acon.acon.core.model.type.RestaurantFeatureType import com.acon.acon.core.model.type.SpotType +import com.acon.acon.feature.upload.screen.SelectedFeature import com.acon.acon.feature.upload.screen.UploadPlaceUiState import com.acon.acon.feature.upload.screen.composable.add.UploadPlaceSelectItem import com.acon.acon.feature.upload.screen.composable.type.getNameResId @@ -36,18 +38,22 @@ internal fun UploadSelectPlaceDetailScreen( state: UploadPlaceUiState, onUpdateCafeOptionType: (CafeFeatureType.CafeType) -> Unit, onUpdateRestaurantType: (RestaurantFeatureType.RestaurantType) -> Unit, - onUpdateNextPageBtnEnabled: (Boolean) -> Unit + onUpdateNextPageBtnEnabled: (Boolean) -> Unit, + onAnimationEnded: (String) -> Unit ) { + val hasAnimatedRestaurant = state.hasAnimated["3"] ?: false + val hasAnimatedCafe = state.hasAnimated["4"] ?: false + val allRestaurantTypes = remember { persistentListOf(*RestaurantFeatureType.RestaurantType.entries.toTypedArray()) } val isNextPageBtnEnabled by remember(state) { derivedStateOf { - when (state.selectedSpotType) { - SpotType.RESTAURANT -> state.selectedRestaurantTypes.isNotEmpty() - SpotType.CAFE -> state.selectedCafeOption != null - else -> false + when(val feature = state.selectedFeature) { + is SelectedFeature.Restaurant -> feature.types.isNotEmpty() + is SelectedFeature.Cafe -> true + null -> false } } } @@ -68,14 +74,17 @@ internal fun UploadSelectPlaceDetailScreen( text = stringResource(R.string.required_field), style = AconTheme.typography.Body1, color = AconTheme.color.Danger, - modifier = Modifier.padding(top = 40.dp) + modifier = Modifier + .padding(top = 40.dp) + .then(if (!hasAnimatedRestaurant) Modifier.slideUpAnimation(order = 1) else Modifier) ) Spacer(Modifier.height(4.dp)) Text( text = stringResource(R.string.upload_place_select_restaurant_title), style = AconTheme.typography.Headline3, - color = AconTheme.color.White + color = AconTheme.color.White, + modifier = Modifier.then(if (!hasAnimatedRestaurant) Modifier.slideUpAnimation(order = 2) else Modifier) ) Spacer(Modifier.height(10.dp)) @@ -83,14 +92,28 @@ internal fun UploadSelectPlaceDetailScreen( text = stringResource(R.string.upload_place_select_cafe_sub_title), style = AconTheme.typography.Title5, color = AconTheme.color.Gray500, - fontWeight = FontWeight.Normal + fontWeight = FontWeight.Normal, + modifier = Modifier + .then( + if (!hasAnimatedRestaurant) Modifier.slideUpAnimation( + hasCaption = true, + order = 3 + ) else Modifier + ) ) Spacer(Modifier.height(32.dp)) Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 4.dp), + .padding(horizontal = 4.dp) + .then( + if (!hasAnimatedRestaurant) Modifier.slideUpAnimation( + hasCaption = true, + order = 4, + onAnimationEnded = { onAnimationEnded("3") } + ) else Modifier + ), verticalArrangement = Arrangement.spacedBy(10.dp) ) { allRestaurantTypes.chunked(2).fastForEach { pair -> @@ -102,7 +125,7 @@ internal fun UploadSelectPlaceDetailScreen( UploadPlaceSelectItem( title = stringResource(id = restaurantType.getNameResId()), modifier = Modifier.weight(1f), - isSelected = state.selectedRestaurantTypes.contains(restaurantType), + isSelected = (state.selectedFeature as? SelectedFeature.Restaurant)?.types?.contains(restaurantType) == true, onClickUploadPlaceSelectItem = { onUpdateRestaurantType(restaurantType) } @@ -118,33 +141,46 @@ internal fun UploadSelectPlaceDetailScreen( text = stringResource(R.string.required_field), style = AconTheme.typography.Body1, color = AconTheme.color.Danger, - modifier = Modifier.padding(top = 40.dp) + modifier = Modifier + .padding(top = 40.dp) + .then(if (!hasAnimatedCafe) Modifier.slideUpAnimation(order = 1) else Modifier) + + ) Spacer(Modifier.height(10.dp)) Text( text = stringResource(R.string.upload_place_select_restaurant_cafe), style = AconTheme.typography.Headline3, - color = AconTheme.color.White + color = AconTheme.color.White, + modifier = Modifier + .then(if (!hasAnimatedCafe) Modifier.slideUpAnimation(order = 2) else Modifier) ) Spacer(Modifier.height(32.dp)) Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp), + .padding(horizontal = 8.dp) + .then( + if (!hasAnimatedCafe) Modifier.slideUpAnimation( + hasCaption = false, + order = 4, + onAnimationEnded = { onAnimationEnded("4") } + ) else Modifier + ), verticalArrangement = Arrangement.spacedBy(12.dp) ) { UploadPlaceSelectItem( - title = stringResource(CafeFeatureType.CafeType.GOOD_FOR_WORK.getNameResId()), - isSelected = state.selectedCafeOption == CafeFeatureType.CafeType.GOOD_FOR_WORK, + title = stringResource(CafeFeatureType.CafeType.WORK_FRIENDLY.getNameResId()), + isSelected = (state.selectedFeature as? SelectedFeature.Cafe)?.option == CafeFeatureType.CafeType.WORK_FRIENDLY, onClickUploadPlaceSelectItem = { - onUpdateCafeOptionType(CafeFeatureType.CafeType.GOOD_FOR_WORK) + onUpdateCafeOptionType(CafeFeatureType.CafeType.WORK_FRIENDLY) } ) UploadPlaceSelectItem( title = stringResource(CafeFeatureType.CafeType.NOT_GOOD_FOR_WORK.getNameResId()), - isSelected = state.selectedCafeOption == CafeFeatureType.CafeType.NOT_GOOD_FOR_WORK, + isSelected = (state.selectedFeature as? SelectedFeature.Cafe)?.option == CafeFeatureType.CafeType.NOT_GOOD_FOR_WORK, onClickUploadPlaceSelectItem = { onUpdateCafeOptionType(CafeFeatureType.CafeType.NOT_GOOD_FOR_WORK) } @@ -165,7 +201,8 @@ private fun UploadSelectPlaceDetailScreenPreview() { state = UploadPlaceUiState(), onUpdateCafeOptionType = {}, onUpdateRestaurantType = {}, - onUpdateNextPageBtnEnabled = {} + onUpdateNextPageBtnEnabled = {}, + onAnimationEnded = {} ) } } \ No newline at end of file diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/place/UploadSelectPlaceScreen.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/place/UploadSelectPlaceScreen.kt index b20a8e97b..3dca4548b 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/place/UploadSelectPlaceScreen.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/place/UploadSelectPlaceScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.animation.slideUpAnimation import com.acon.acon.core.designsystem.theme.AconTheme import com.acon.acon.core.model.type.SpotType import com.acon.acon.feature.upload.screen.UploadPlaceUiState @@ -30,8 +31,11 @@ import com.acon.acon.feature.upload.screen.composable.type.getNameResId internal fun UploadSelectPlaceScreen( state: UploadPlaceUiState, onSelectSpotType: (SpotType) -> Unit, - onUpdateNextPageBtnEnabled: (Boolean) -> Unit + onUpdateNextPageBtnEnabled: (Boolean) -> Unit, + onAnimationEnded: (String) -> Unit ) { + val hasAnimated = state.hasAnimated["2"] ?: false + val isNextPageBtnEnabled by remember(state) { derivedStateOf { state.selectedSpotType != null @@ -52,14 +56,17 @@ internal fun UploadSelectPlaceScreen( text = stringResource(R.string.required_field), style = AconTheme.typography.Body1, color = AconTheme.color.Danger, - modifier = Modifier.padding(top = 40.dp) + modifier = Modifier + .padding(top = 40.dp) + .then(if (!hasAnimated) Modifier.slideUpAnimation(order = 1) else Modifier) ) Spacer(Modifier.height(4.dp)) Text( text = stringResource(R.string.upload_place_select_place_title), style = AconTheme.typography.Headline3, - color = AconTheme.color.White + color = AconTheme.color.White, + modifier = Modifier.then(if (!hasAnimated) Modifier.slideUpAnimation(order = 2) else Modifier) ) Spacer(Modifier.height(10.dp)) @@ -67,14 +74,28 @@ internal fun UploadSelectPlaceScreen( text = stringResource(R.string.upload_place_select_place_sub_title), style = AconTheme.typography.Title5, color = AconTheme.color.Gray500, - fontWeight = FontWeight.Normal + fontWeight = FontWeight.Normal, + modifier = Modifier + .then( + if (!hasAnimated) Modifier.slideUpAnimation( + hasCaption = true, + order = 3 + ) else Modifier + ) ) Spacer(Modifier.height(32.dp)) Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp), + .padding(horizontal = 8.dp) + .then( + if (!hasAnimated) Modifier.slideUpAnimation( + hasCaption = true, + order = 4, + onAnimationEnded = { onAnimationEnded("2") } + ) else Modifier + ), verticalArrangement = Arrangement.spacedBy(12.dp) ) { UploadPlaceSelectItem( @@ -103,6 +124,7 @@ private fun UploadSelectPlaceScreenPreview() { state = UploadPlaceUiState(), onSelectSpotType = {}, onUpdateNextPageBtnEnabled = {}, + onAnimationEnded = {} ) } } \ No newline at end of file diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/price/UploadSelectPriceScreen.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/price/UploadSelectPriceScreen.kt index eaed138b1..f1893a446 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/price/UploadSelectPriceScreen.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/price/UploadSelectPriceScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.animation.slideUpAnimation import com.acon.acon.core.designsystem.theme.AconTheme import com.acon.acon.core.model.type.PriceFeatureType import com.acon.acon.feature.upload.screen.UploadPlaceUiState @@ -29,8 +30,11 @@ import com.acon.acon.feature.upload.screen.composable.type.getNameResId internal fun UploadSelectPriceScreen( state: UploadPlaceUiState, onUpdatePriceOptionType: (PriceFeatureType.PriceOptionType) -> Unit, - onUpdateNextPageBtnEnabled: (Boolean) -> Unit + onUpdateNextPageBtnEnabled: (Boolean) -> Unit, + onAnimationEnded: (String) -> Unit ) { + val hasAnimated = state.hasAnimated["6"] ?: false + val isNextPageBtnEnabled by remember(state) { derivedStateOf { state.selectedPriceOption != null @@ -51,7 +55,9 @@ internal fun UploadSelectPriceScreen( text = stringResource(R.string.required_field), style = AconTheme.typography.Body1, color = AconTheme.color.Danger, - modifier = Modifier.padding(top = 40.dp) + modifier = Modifier + .padding(top = 40.dp) + .then(if (!hasAnimated) Modifier.slideUpAnimation(order = 1) else Modifier) ) Spacer(Modifier.height(4.dp)) @@ -59,14 +65,23 @@ internal fun UploadSelectPriceScreen( text = stringResource(R.string.upload_place_select_price_title), style = AconTheme.typography.Headline3, color = AconTheme.color.White, - modifier = Modifier.padding(2.dp) + modifier = Modifier + .padding(2.dp) + .then(if (!hasAnimated) Modifier.slideUpAnimation(order = 2) else Modifier) ) Spacer(Modifier.height(32.dp)) Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp), + .padding(horizontal = 8.dp) + .then( + if (!hasAnimated) Modifier.slideUpAnimation( + hasCaption = true, + order = 3, + onAnimationEnded = { onAnimationEnded("6") } + ) else Modifier + ), verticalArrangement = Arrangement.spacedBy(12.dp) ) { UploadPlaceSelectItem( @@ -103,7 +118,8 @@ private fun UploadSelectPriceScreenPreview( UploadSelectPriceScreen( state = UploadPlaceUiState(), onUpdatePriceOptionType = {}, - onUpdateNextPageBtnEnabled = {} + onUpdateNextPageBtnEnabled = {}, + onAnimationEnded = {} ) } } \ No newline at end of file diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/search/UploadPlaceSearchScreen.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/search/UploadPlaceSearchScreen.kt index 005e906d4..c2e5a7084 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/search/UploadPlaceSearchScreen.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/add/search/UploadPlaceSearchScreen.kt @@ -51,6 +51,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.animation.slideUpAnimation import com.acon.acon.core.designsystem.component.dialog.v2.AconTwoActionDialog import com.acon.acon.core.designsystem.component.textfield.v2.AconSearchTextField import com.acon.acon.core.designsystem.effect.LocalHazeState @@ -70,7 +71,8 @@ internal fun UploadPlaceSearchScreen( onClickReportPlace: () -> Unit, onHideSearchedPlaceList: () -> Unit, onSearchedSpotClick: (SearchedSpotByMap, onUpdateTextField: () -> Unit) -> Unit, - onSearchQueryOrSelectionChanged: (String, Boolean) -> Unit + onSearchQueryOrSelectionChanged: (String, Boolean) -> Unit, + onAnimationEnded: (String) -> Unit ) { val lifecycleOwner = LocalLifecycleOwner.current @@ -90,6 +92,8 @@ internal fun UploadPlaceSearchScreen( animationSpec = tween(durationMillis = 300) ) + val hasAnimated = state.hasAnimated["1"] ?: false + LaunchedEffect(Unit) { snapshotFlow { query }.collect { onSearchQueryOrSelectionChanged(it.text, isSelection) @@ -128,6 +132,7 @@ internal fun UploadPlaceSearchScreen( } } + Column( modifier = Modifier .fillMaxSize() @@ -155,17 +160,21 @@ internal fun UploadPlaceSearchScreen( text = stringResource(R.string.required_field), style = AconTheme.typography.Body1, color = AconTheme.color.Danger, - modifier = Modifier.padding(top = 40.dp) + modifier = Modifier + .padding(top = 40.dp) + .then(if (!hasAnimated) Modifier.slideUpAnimation(order = 1) else Modifier) ) Spacer(Modifier.height(4.dp)) Text( text = stringResource(R.string.upload_place_search_title), style = AconTheme.typography.Headline3, - color = AconTheme.color.White + color = AconTheme.color.White, + modifier = Modifier + .then(if (!hasAnimated) Modifier.slideUpAnimation(order = 2) else Modifier) ) - Spacer(Modifier.height(20.dp)) + Spacer(Modifier.height(32.dp)) } Column { @@ -189,36 +198,42 @@ internal fun UploadPlaceSearchScreen( ), modifier = Modifier .fillMaxWidth() + .then( + if (!hasAnimated) Modifier.slideUpAnimation( + order = 4, + onAnimationEnded = { onAnimationEnded("1") } + ) else Modifier + ) .onFocusChanged { focusState -> if (focusState.isFocused) { showOffset = true } } ) - Box { - if (state.showSearchedSpotsByMap) { - SearchedSpots( - searchedSpotsByMap = state.searchedSpotsByMap.toImmutableList(), - onItemClick = { - keyboardController?.hide() - focusManager.clearFocus() - onSearchedSpotClick(it) { - isSelection = true - query = TextFieldValue( - text = it.title, - selection = TextRange(it.title.length) - ) - showOffset = false - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp) - .height(300.dp) - .clip(RoundedCornerShape(10.dp)) - .background(AconTheme.color.GlassWhiteLight) - ) - } + } + Box { + if (state.showSearchedSpotsByMap) { + SearchedSpots( + searchedSpotsByMap = state.searchedSpotsByMap.toImmutableList(), + onItemClick = { + keyboardController?.hide() + focusManager.clearFocus() + onSearchedSpotClick(it) { + isSelection = true + query = TextFieldValue( + text = it.title, + selection = TextRange(it.title.length) + ) + showOffset = false + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + .height(300.dp) + .clip(RoundedCornerShape(10.dp)) + .background(AconTheme.color.GlassWhiteLight) + ) } } } @@ -316,7 +331,8 @@ private fun UploadPlaceSearchScreenPreview() { onClickReportPlace = {}, onHideSearchedPlaceList = {}, onSearchedSpotClick = { _, _ -> }, - onSearchQueryOrSelectionChanged = { _, _ -> } + onSearchQueryOrSelectionChanged = { _, _ -> }, + onAnimationEnded = {} ) } } \ No newline at end of file diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/menu/UploadEnterMenuScreen.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/menu/UploadEnterMenuScreen.kt index bded388ec..9530fbd95 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/menu/UploadEnterMenuScreen.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/menu/UploadEnterMenuScreen.kt @@ -86,9 +86,11 @@ internal fun UploadEnterMenuScreen( Spacer(Modifier.height(32.dp)) AconSearchTextField( value = query, - onValueChange = { - query = it - isSelection = false + onValueChange = { newValue -> + if (newValue.text.length <= 30) { + query = newValue + isSelection = false + } }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/menu/UploadPlaceEnterMenuScreen.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/menu/UploadPlaceEnterMenuScreen.kt index 196578ed4..07e5b52c0 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/menu/UploadPlaceEnterMenuScreen.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/menu/UploadPlaceEnterMenuScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.animation.slideUpAnimation import com.acon.acon.core.designsystem.component.textfield.v2.AconOutlinedSearchTextField import com.acon.acon.core.designsystem.theme.AconTheme import com.acon.acon.feature.upload.screen.UploadPlaceUiState @@ -38,8 +39,11 @@ import com.acon.acon.feature.upload.screen.UploadPlaceUiState internal fun UploadPlaceEnterMenuScreen( state: UploadPlaceUiState, onSearchQueryChanged: (String) -> Unit, - onUpdateNextPageBtnEnabled: (Boolean) -> Unit + onUpdateNextPageBtnEnabled: (Boolean) -> Unit, + onAnimationEnded: (String) -> Unit ) { + val hasAnimated = state.hasAnimated["5"] ?: false + val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current @@ -65,42 +69,57 @@ internal fun UploadPlaceEnterMenuScreen( }) } ) { - Text( - text = stringResource(R.string.required_field), - style = AconTheme.typography.Body1, - color = AconTheme.color.Danger, - modifier = Modifier.padding(top = 40.dp) - ) + Column { + Text( + text = stringResource(R.string.required_field), + style = AconTheme.typography.Body1, + color = AconTheme.color.Danger, + modifier = Modifier + .padding(top = 40.dp) + .then(if (!hasAnimated) Modifier.slideUpAnimation(order = 1) else Modifier) + ) - Text( - text = stringResource(R.string.upload_place_enter_menu_title), - style = AconTheme.typography.Headline3, - color = AconTheme.color.White, - modifier = Modifier.padding(top = 4.dp, start = 2.dp) - ) + Text( + text = stringResource(R.string.upload_place_enter_menu_title), + style = AconTheme.typography.Headline3, + color = AconTheme.color.White, + modifier = Modifier + .padding(top = 4.dp, start = 2.dp) + .then(if (!hasAnimated) Modifier.slideUpAnimation(order = 2) else Modifier) + ) - Spacer(Modifier.height(32.dp)) - AconOutlinedSearchTextField( - value = query, - onValueChange = { newValue -> - if (newValue.text.length <= 20) { - query = newValue - isSelection = false - } - }, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - keyboardType = KeyboardType.Text - ), - keyboardActions = KeyboardActions( - onDone = { - keyboardController?.hide() - focusManager.clearFocus() - } - ), - placeholder = stringResource(R.string.upload_place_enter_menu_placeholder), - modifier = Modifier.fillMaxWidth() - ) + Spacer(Modifier.height(32.dp)) + } + Column { + AconOutlinedSearchTextField( + value = query, + onValueChange = { newValue -> + if (newValue.text.length <= 30) { + query = newValue + isSelection = false + } + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Text + ), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + focusManager.clearFocus() + } + ), + placeholder = stringResource(R.string.upload_place_enter_menu_placeholder), + modifier = Modifier + .fillMaxWidth() + .then( + if (!hasAnimated) Modifier.slideUpAnimation( + order = 4, + onAnimationEnded = { onAnimationEnded("5") } + ) else Modifier + ) + ) + } } } @@ -111,7 +130,8 @@ private fun UploadPlaceEnterMenuScreenPreview() { UploadPlaceEnterMenuScreen( state = UploadPlaceUiState(), onSearchQueryChanged = {}, - onUpdateNextPageBtnEnabled = {} + onUpdateNextPageBtnEnabled = {}, + onAnimationEnded = {} ) } } \ No newline at end of file diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/type/TypeExtensions.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/type/TypeExtensions.kt index 84c7f6ee4..d6a118c69 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/type/TypeExtensions.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/composable/type/TypeExtensions.kt @@ -38,7 +38,7 @@ internal fun RestaurantFeatureType.RestaurantType.getNameResId(): Int { internal fun CafeFeatureType.CafeType.getNameResId(): Int { return when(this) { - CafeFeatureType.CafeType.GOOD_FOR_WORK -> R.string.upload_place_select_cafe_option1 + CafeFeatureType.CafeType.WORK_FRIENDLY -> R.string.upload_place_select_cafe_option1 CafeFeatureType.CafeType.NOT_GOOD_FOR_WORK -> R.string.upload_place_select_cafe_option2 } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c4c3df544..dc3f63fb3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,8 +4,8 @@ projectApplicationId = "com.acon.acon" projectCompileSdk = "35" projectTargetSdk = "35" projectMinSdk = "28" -projectVersionCode = "20000003" -projectVersionName = "2.0.0" +projectVersionCode = "20010001" +projectVersionName = "2.1.0" ####################################### composeCompilerVersion = "1.5.1"