diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 361fe5cb8..35eb1ddfb 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,6 +2,5 @@ - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aac53f2f3..4d94f3921 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -95,7 +95,7 @@ dependencies { projects.feature.examResult, projects.feature.solveProblem, projects.feature.notification, - projects.feature.createProblem, + projects.feature.createExam, projects.feature.startExam, projects.feature.detail, projects.feature.profile, diff --git a/build-logic/src/main/kotlin/team/duckie/app/android/convention/ApplicationConstants.kt b/build-logic/src/main/kotlin/team/duckie/app/android/convention/ApplicationConstants.kt index ed3e1a391..3179eaea9 100644 --- a/build-logic/src/main/kotlin/team/duckie/app/android/convention/ApplicationConstants.kt +++ b/build-logic/src/main/kotlin/team/duckie/app/android/convention/ApplicationConstants.kt @@ -14,9 +14,9 @@ import org.gradle.api.JavaVersion */ internal object ApplicationConstants { const val minSdk = 23 - const val targetSdk = 33 - const val compileSdk = 33 - const val versionCode = 24 + const val targetSdk = 34 + const val compileSdk = 34 + const val versionCode = 30 const val versionName = "1.3.0" val javaVersion = JavaVersion.VERSION_17 } diff --git a/common/android/src/main/kotlin/team/duckie/app/android/common/android/image/MediaUtil.kt b/common/android/src/main/kotlin/team/duckie/app/android/common/android/image/MediaUtil.kt index 89734fe71..45a35c3ee 100644 --- a/common/android/src/main/kotlin/team/duckie/app/android/common/android/image/MediaUtil.kt +++ b/common/android/src/main/kotlin/team/duckie/app/android/common/android/image/MediaUtil.kt @@ -14,6 +14,8 @@ import android.graphics.Matrix import android.media.ExifInterface import android.net.Uri import android.os.Build +import com.google.firebase.crashlytics.FirebaseCrashlytics +import team.duckie.app.android.common.kotlin.AllowMagicNumber import java.io.BufferedInputStream import java.io.File import java.io.FileOutputStream @@ -24,13 +26,13 @@ import java.util.UUID * Media 처리용 유틸 클래스 * // TODO(riflockle7): 추후 ImageUtil 로 바꿀지 고민 */ +@AllowMagicNumber("for MediaUtil") object MediaUtil { private const val maxWidth = 1600 private const val maxHeight = 1600 private const val bitmapQuality = 100 - private const val rotate90 = 90 - private const val rotate180 = 180 - private const val rotate270 = 270 + + private var bitmap: Bitmap? = null /** * 업로드할 이미지를 가져온다. @@ -58,9 +60,9 @@ object MediaUtil { decodeBitmapFromUri(uri, applicationContext, maxSizeLimitEnable)?.apply { compress(Bitmap.CompressFormat.JPEG, bitmapQuality, fos) - recycle() } ?: throw NullPointerException("bitmap 생성 오류") + bitmap = null fos.flush() fos.close() @@ -76,7 +78,6 @@ object MediaUtil { // 인자 값으로 넘어온 입력 스트림을 나중에 사용하기 위해 저장하는 BufferedInputStream 사용 val input = BufferedInputStream(context.contentResolver.openInputStream(uri)) input.mark(input.available()) // 입력 스트림의 특정 위치를 기억 - var bitmap: Bitmap? BitmapFactory.Options().run { if (maxSizeLimitEnable) { @@ -130,23 +131,44 @@ object MediaUtil { ExifInterface(uri.path!!) } - return when ( - exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL, - ) - ) { - ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, rotate90) - ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, rotate180) - ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, rotate270) - else -> bitmap - } + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + return rotateBitmap(bitmap, orientation) } /** 이미지를 회전한다. */ - private fun rotateImage(bitmap: Bitmap, degree: Int): Bitmap? { + private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap? { val matrix = Matrix() - matrix.postRotate(degree.toFloat()) - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + when (orientation) { + ExifInterface.ORIENTATION_NORMAL -> return bitmap + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + matrix.setRotate(180f) + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.setRotate(90f) + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f) + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.setRotate(-90f) + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f) + else -> return bitmap + } + return try { + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } catch (e: OutOfMemoryError) { + FirebaseCrashlytics.getInstance().recordException(e) + null + } } } diff --git a/common/android/src/main/kotlin/team/duckie/app/android/common/android/timer/ProblemTimer.kt b/common/android/src/main/kotlin/team/duckie/app/android/common/android/timer/ProblemTimer.kt index 85825c6f3..d138b0eed 100644 --- a/common/android/src/main/kotlin/team/duckie/app/android/common/android/timer/ProblemTimer.kt +++ b/common/android/src/main/kotlin/team/duckie/app/android/common/android/timer/ProblemTimer.kt @@ -44,4 +44,8 @@ class ProblemTimer( fun stop() { timerJob?.cancel() } + + fun setTotalTime(time: Float) { + _remainingTime.value = time + } } diff --git a/common/android/src/main/kotlin/team/duckie/app/android/common/android/ui/const/Extras.kt b/common/android/src/main/kotlin/team/duckie/app/android/common/android/ui/const/Extras.kt index da91eb0d5..cc9bc515b 100644 --- a/common/android/src/main/kotlin/team/duckie/app/android/common/android/ui/const/Extras.kt +++ b/common/android/src/main/kotlin/team/duckie/app/android/common/android/ui/const/Extras.kt @@ -21,6 +21,9 @@ object Extras { const val SearchTag = "ExtraSearchTag" const val StartGuide = "StartGuide" + /** using for SearchActivity */ + const val AutoFocusing = "AutoFocusing" + /** using for FriendsActivity */ const val FriendType = "ExtraFriendType" const val ProfileNickName = "ProfileNickName" diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/Keyboard.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/Keyboard.kt index cfa8b05cd..4d70ace24 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/Keyboard.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/Keyboard.kt @@ -5,17 +5,25 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalMaterialApi::class) + package team.duckie.app.android.common.compose import android.graphics.Rect import android.view.ViewTreeObserver +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import team.duckie.app.android.common.kotlin.AllowMagicNumber @@ -63,3 +71,17 @@ fun Modifier.composedWithKeyboardVisibility( .composed { if (keyboardVisible.value) whenKeyboardVisible() else this } .composed { if (!keyboardVisible.value) whenKeyboardHidden() else this } } + +@Composable +fun HideKeyboardWhenBottomSheetHidden(sheetState: ModalBottomSheetState) { + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + val sheetStateFlow = snapshotFlow { sheetState.currentValue } + sheetStateFlow.collect { state -> + if (state == ModalBottomSheetValue.Hidden) { + keyboard?.hide() + } + } + } +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagerState.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagerState.kt index e28999974..506df36f9 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagerState.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagerState.kt @@ -31,3 +31,7 @@ suspend inline fun PagerState.moveNextPage(maxPage: Int) { fun PagerState.isCurrentPage(targetIndex: Int): Boolean { return currentPageOffsetFraction == 0f && targetIndex == currentPage } + +fun PagerState.isTargetPage(targetIndex: Int): Boolean { + return targetIndex == targetPage +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagingItemsKey.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagingItemsKey.kt index c77aa1e9f..f7116edbc 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagingItemsKey.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagingItemsKey.kt @@ -30,3 +30,11 @@ fun itemsPagingKey( key(index) ?: index } } + +/** + * 서버측 에러로 PK가 중복으로 내려오는 경우 방치 + * + * @return PK + [secondId] 형식의 String + */ +fun Int?.getUniqueKey(secondId: Int): String = + this.toString() + secondId diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/constants.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/constants.kt index 8388c1a38..53d2dde1f 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/constants.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/constants.kt @@ -11,3 +11,12 @@ package team.duckie.app.android.common.compose // 328 x 240 비율에서 이미지의 높이값을 가져올 수 있는 비율값 const val GetHeightRatioW328H240 = 328.toFloat() / 240 + +// 4 x 1 비율에서 이미지의 높이값을 가져올 수 있는 비율값 +const val GetHeightRatioW360H90 = 4.toFloat() / 1 + +// 85 x 63 비율에서 이미지의 높이값을 가져올 수 있는 비율값 +const val GetHeightRatioW85H63 = 85.toFloat() / 63 + +// 129 x 84 비율에서 이미지의 높이값을 가져올 수 있는 비율값 +const val GetHeightRatioW129H84 = 129.toFloat() / 84 diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/BackPressedHeadLineTopAppBar.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/BackPressedHeadLineTopAppBar.kt index 11b69f507..e4c6bc0b6 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/BackPressedHeadLineTopAppBar.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/BackPressedHeadLineTopAppBar.kt @@ -72,6 +72,7 @@ fun BackPressedHeadLineTopAppBar( fun BackPressedHeadLine2TopAppBar( title: String, isLoading: Boolean = false, + trailingContent: (@Composable () -> Unit)? = null, onBackPressed: () -> Unit, ) { BackPressedTopAppBar(onBackPressed = onBackPressed) { @@ -79,5 +80,9 @@ fun BackPressedHeadLine2TopAppBar( modifier = Modifier.skeleton(isLoading), text = title, ) + Spacer(weight = 1f) + if (trailingContent != null) { + trailingContent() + } } } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/Divider.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/Divider.kt index f4cd2cf43..294e60293 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/Divider.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/Divider.kt @@ -54,3 +54,28 @@ fun DuckieDivider( .background(color = color.value), ) } + +/** + * QuackDivider 를 그리기 위한 리소스들을 정의합니다. + */ +private object QuackDividerDefaults { + val Color = QuackColor.Gray3 + val Height = 1.dp +} + +/** + * 덕키에서 사용되는 구분선(divider)을 그립니다. + * + * @param modifier 이 컴포넌트에 사용할 [Modifier] + */ +@Composable +public fun QuackDivider(modifier: Modifier = Modifier) { + with(QuackDividerDefaults) { + Box( + modifier = modifier + .fillMaxWidth() + .height(Height) + .background(color = Color.value), + ) + } +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckExamCoverItem.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckExamCoverItem.kt index 76e2ad1a8..abec7ead4 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckExamCoverItem.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckExamCoverItem.kt @@ -28,6 +28,7 @@ import coil.compose.AsyncImage import team.duckie.app.android.common.compose.R import team.duckie.app.android.common.compose.ui.icon.v1.MoreId import team.duckie.app.android.common.kotlin.runIf +import team.duckie.app.android.common.kotlin.toDecimalFormat import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon @@ -140,7 +141,7 @@ internal fun DuckSmallCoverInternal( horizontalArrangement = Arrangement.spacedBy(2.dp), ) { QuackText( - text = "${stringResource(id = R.string.examinee)} ${duckTestCoverItem.solvedCount}", + text = "${stringResource(id = R.string.examinee)} ${duckTestCoverItem.solvedCount.toDecimalFormat()}", typography = QuackTypography.Body2.change(color = QuackColor.Gray2), ) QuackText( @@ -148,7 +149,7 @@ internal fun DuckSmallCoverInternal( typography = QuackTypography.Body2.change(color = QuackColor.Gray2), ) QuackText( - text = "${stringResource(id = R.string.heart)} ${duckTestCoverItem.heartCount}", + text = "${stringResource(id = R.string.heart)} ${duckTestCoverItem.heartCount.toDecimalFormat()}", typography = QuackTypography.Body2.change(color = QuackColor.Gray2), ) } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckieHorizontalPagerIndicator.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckieHorizontalPagerIndicator.kt index d9be1005a..569c0b04e 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckieHorizontalPagerIndicator.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckieHorizontalPagerIndicator.kt @@ -75,10 +75,20 @@ fun DuckieHorizontalPagerIndicator( Box( Modifier .offset { - val scrollPosition = (pagerState.currentPage + pagerState.currentPageOffsetFraction) - .coerceIn(0f, pagerState.currentPage.coerceAtLeast(0).toFloat()) + val scrollPosition = + (pagerState.currentPage + pagerState.currentPageOffsetFraction) + .coerceIn( + minimumValue = 0f, + maximumValue = pagerState.currentPage + .coerceAtLeast(0) + .toFloat(), + ) IntOffset( - x = ((spacingPx + indicatorWidthPx) * scrollPosition).toInt(), + x = if (pagerState.targetPage == 0) { + 0 + } else { + ((spacingPx + indicatorWidthPx) * scrollPosition).toInt() + }, y = 0, ) } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/EmptyText.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/EmptyText.kt index 98c419036..7fc50805c 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/EmptyText.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/EmptyText.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import team.duckie.quackquack.material.QuackColor diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/FavoriteTagSection.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/FavoriteTagSection.kt index f3b93e2f3..4eb3b04ee 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/FavoriteTagSection.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/FavoriteTagSection.kt @@ -14,16 +14,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import team.duckie.app.android.common.compose.ui.quack.todo.QuackLazyVerticalGridTag -import team.duckie.app.android.common.compose.ui.quack.todo.QuackSingeLazyRowTag import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackTitle2 -import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon /** * 관심태그 섹션 @@ -48,10 +47,8 @@ fun FavoriteTagSection( title: String, horizontalPadding: PaddingValues = PaddingValues(0.dp), verticalArrangement: Arrangement.HorizontalOrVertical = Arrangement.spacedBy(0.dp), - // TODO(riflockle7): 다음 작업에서 QuackV2 로 대응하기 - trailingIcon: QuackV1Icon? = null, + trailingIcon: ImageVector? = null, onTrailingClick: ((Int) -> Unit)? = null, - singleLine: Boolean = true, emptySection: @Composable () -> Unit, tags: ImmutableList, onTagClick: (Int) -> Unit, @@ -74,25 +71,15 @@ fun FavoriteTagSection( // 태그가 없을 경우 표시되는 Section emptySection() } else { - if (singleLine) { - // 한 줄로 표현되는 태그 목록 - QuackSingeLazyRowTag( - items = tagList, - contentPadding = horizontalPadding, - tagTypeResId = trailingIcon?.drawableId, - onClick = { index -> onTagClick(index) }, - ) - } else { - // 여러 줄로 표현되는 태그 목록 - QuackLazyVerticalGridTag( - contentPadding = horizontalPadding, - horizontalSpace = 4.dp, - items = tagList, - tagTypeResId = trailingIcon?.drawableId, - onClick = { index -> onTagClick(index) }, - itemChunkedSize = 4, - ) - } + QuackLazyVerticalGridTag( + contentPadding = horizontalPadding, + horizontalSpace = 4.dp, + items = tagList, + trailingIcon = trailingIcon, + onTrailingClick = onTrailingClick, + onClick = { index -> onTagClick(index) }, + itemChunkedSize = 4, + ) } // 추가 버튼 diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/PhotoPicker.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/PhotoPicker.kt index 04bcc156e..45f8e31cd 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/PhotoPicker.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/PhotoPicker.kt @@ -33,7 +33,6 @@ import com.ujizin.camposer.state.rememberCameraState import kotlinx.collections.immutable.ImmutableList import team.duckie.app.android.common.compose.R import team.duckie.app.android.common.compose.ui.icon.v1.CameraId -import team.duckie.app.android.common.compose.ui.icon.v1.CloseId import team.duckie.app.android.common.compose.ui.quack.todo.QuackGridLayout import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImage import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar @@ -42,6 +41,8 @@ import team.duckie.app.android.common.kotlin.runIf import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackImage import team.duckie.quackquack.ui.QuackText @@ -116,7 +117,11 @@ fun PhotoPicker( Column(modifier = modifier.zIndex(zIndex)) { QuackTopAppBar( - leadingIconResId = QuackIcon.CloseId, + modifier = Modifier.padding( + start = 12.dp, + end = 16.dp, + ), + leadingIcon = QuackIcon.Outlined.Close, onLeadingIconClick = onCloseClick, centerText = stringResource(R.string.topappbar_filter_full), trailingContent = { @@ -128,11 +133,7 @@ fun PhotoPicker( rippleEnabled = false, onClick = onAddClick, ) - } - .padding( - horizontal = 16.dp, - vertical = 15.dp, - ), + }, text = stringResource(R.string.topappbar_add), typography = QuackTypography.Subtitle.change( color = if (isAddable) { diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/TextTabLayout.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/TextTabLayout.kt index d4a7148fe..03cfa56e9 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/TextTabLayout.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/TextTabLayout.kt @@ -23,8 +23,8 @@ import team.duckie.quackquack.ui.QuackText @Composable fun TextTabLayout( titles: ImmutableList, - selectedTabStyle: QuackTypography = QuackTypography.HeadLine2, - tabStyle: QuackTypography = QuackTypography.Title2, + selectedTabStyle: QuackTypography = QuackTypography.HeadLine1, + tabStyle: QuackTypography = QuackTypography.HeadLine2, selectedTabIndex: Int, onTabSelected: (Int) -> Unit, space: Dp = 12.dp, diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/BasicContentLayout.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/BasicContentLayout.kt new file mode 100644 index 000000000..34a333291 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/BasicContentLayout.kt @@ -0,0 +1,166 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.content + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.asLoose +import team.duckie.app.android.common.compose.centerVerticalWithMaxHeight +import team.duckie.app.android.common.compose.ui.skeleton +import team.duckie.app.android.common.kotlin.fastFirstOrNull +import team.duckie.app.android.common.kotlin.npe +import team.duckie.app.android.common.kotlin.runIf +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.sugar.QuackBody3 +import team.duckie.quackquack.ui.sugar.QuackSubtitle2 + +@Stable +private object UserContentWithButtonDefaults { + const val LeadingImageLayoutId = "LeadingImageLayoutId" + const val TitleLayoutId = "TitleLayoutId" + const val DescriptionLayoutId = "DescriptionLayoutId" + const val TrailingButtonLayoutId = "TrailingButtonLayoutId" +} + +/** + * 해당 버튼을 사용하기 위해선 [trailingButton] 에 modifier 를 넣어야 합니다. + */ +@Composable +internal fun BasicContentWithButtonLayout( + modifier: Modifier = Modifier, + contentId: Int, + nickname: String, + rippleEnabled: Boolean = true, + description: String, + onClickLayout: ((Int) -> Unit)? = null, + visibleTrailingButton: Boolean = false, + isTitleCenter: Boolean = false, + visibleHorizontalPadding: Boolean = true, + leadingImageContent: @Composable (modifier: Modifier) -> Unit, + trailingButton: @Composable (modifier: Modifier) -> Unit, + isLoading: Boolean = false, +) = with(UserContentWithButtonDefaults) { + Layout( + modifier = modifier + .quackClickable( + rippleEnabled = rippleEnabled, + onClick = { + if (onClickLayout != null) { + onClickLayout(contentId) + } + }, + ) + .fillMaxWidth() + .height(56.dp) + .padding(vertical = 12.dp) + .runIf(visibleHorizontalPadding) { + padding(horizontal = 16.dp) + }, + content = { + leadingImageContent( + modifier = modifier + .layoutId(LeadingImageLayoutId) + .skeleton(isLoading), + ) + QuackSubtitle2( + modifier = Modifier + .layoutId(TitleLayoutId) + .padding(start = 8.dp) + .skeleton(isLoading), + text = nickname, + ) + QuackBody3( + modifier = Modifier + .layoutId(DescriptionLayoutId) + .padding(start = 8.dp) + .skeleton(isLoading), + text = description, + ) + trailingButton.invoke( + modifier = modifier + .layoutId(TrailingButtonLayoutId) + .skeleton(isLoading), + ) + }, + measurePolicy = getUserContentLayoutMeasurePolicy( + isTitleCenter = isTitleCenter, + visibleTrailingButton = visibleTrailingButton, + ), + ) +} + +private fun getUserContentLayoutMeasurePolicy( + isTitleCenter: Boolean, + visibleTrailingButton: Boolean, +) = MeasurePolicy { measurables, constraints -> + with(UserContentWithButtonDefaults) { + val extraLooseConstraints = constraints.asLoose(width = true) + + val leadingImagePlaceable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == LeadingImageLayoutId + }?.measure(extraLooseConstraints) ?: npe() + + val titlePlaceable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == TitleLayoutId + }?.measure(extraLooseConstraints) ?: npe() + + val descriptionPlaceable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == DescriptionLayoutId + }?.measure(extraLooseConstraints) ?: npe() + + val trailingButtonPlaceable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == TrailingButtonLayoutId + }?.measure(extraLooseConstraints) ?: npe() + + layout( + width = constraints.maxWidth, + height = constraints.maxHeight, + ) { + leadingImagePlaceable.place( + x = 0, + y = 0, + ) + + titlePlaceable.place( + x = leadingImagePlaceable.width, + y = if (isTitleCenter) { + constraints.centerVerticalWithMaxHeight(titlePlaceable.height) + } else { + 0 + }, + ) + + descriptionPlaceable.place( + x = leadingImagePlaceable.width, + y = titlePlaceable.height, + ) + + if (visibleTrailingButton) { + trailingButtonPlaceable.place( + x = constraints.maxWidth - trailingButtonPlaceable.width, + y = constraints.centerVerticalWithMaxHeight(trailingButtonPlaceable.height), + ) + } + } + } +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/IgnoreContentLayout.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/IgnoreContentLayout.kt new file mode 100644 index 000000000..ede4f834e --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/IgnoreContentLayout.kt @@ -0,0 +1,126 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn(ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.common.compose.ui.content + +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.CoverImageRatio +import team.duckie.app.android.common.compose.R +import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackButton +import team.duckie.quackquack.ui.QuackButtonStyle +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +private val ProfileImageSize: DpSize = DpSize(32.dp, 32.dp) + +@Composable +fun ExamIgnoreLayout( + modifier: Modifier = Modifier, + examId: Int, + examThumbnailUrl: String, + name: String, + likeNum: Int? = null, + solverNum: Int? = null, + onClickUserProfile: ((Int) -> Unit)? = null, + visibleTrailingButton: Boolean = true, + onClickTrailingButton: (Int) -> Unit, + isLoading: Boolean = false, + rippleEnabled: Boolean = false, +) { + BasicContentWithButtonLayout( + isLoading = isLoading, + modifier = modifier, + contentId = examId, + nickname = name, + onClickLayout = onClickUserProfile, + description = buildString { + append(solverNum ?: "") + append(if (likeNum != null) "· 좋아요 $likeNum" else "") + }, + trailingButton = { + QuackButton( // FIXME(limsaehyun) enabled 버튼 모양이 disabled로 바뀌어버림 + modifier = it.quackClickable { + onClickTrailingButton(examId) + }, + text = stringResource(id = R.string.cancel_igonre), + style = QuackButtonStyle.PrimaryOutlinedSmall, + onClick = { }, + enabled = false, + ) + }, + visibleTrailingButton = visibleTrailingButton, + leadingImageContent = { + QuackImage( + modifier = it + .width(68.dp) + .aspectRatio(CoverImageRatio), + src = examThumbnailUrl, + contentScale = ContentScale.Crop, + ) + }, + isTitleCenter = likeNum == null && solverNum == null, + visibleHorizontalPadding = false, + rippleEnabled = rippleEnabled, + ) +} + +@Composable +fun UserIgnoreLayout( + modifier: Modifier = Modifier, + userId: Int, + profileImageIrl: String, + nickname: String, + favoriteTag: String, + rippleEnabled: Boolean = true, + tier: String, + onClickUserProfile: ((Int) -> Unit)? = null, + visibleTrailingButton: Boolean = true, + onClickTrailingButton: (Int) -> Unit, + isLoading: Boolean = false, +) { + BasicContentWithButtonLayout( + isLoading = isLoading, + modifier = modifier, + contentId = userId, + nickname = nickname, + description = tier + if (favoriteTag.isNotEmpty()) "· $favoriteTag" else "", + onClickLayout = onClickUserProfile, + trailingButton = { + QuackButton( // FIXME(limsaehyun) enabled 버튼 모양이 disabled로 바뀌어버림 + modifier = it.quackClickable { + onClickTrailingButton(userId) + }, + text = stringResource(id = R.string.cancel_igonre), + style = QuackButtonStyle.PrimaryOutlinedSmall, + onClick = { }, + enabled = false, + ) + }, + visibleTrailingButton = visibleTrailingButton, + leadingImageContent = { + QuackProfileImage( + modifier = it, + profileUrl = profileImageIrl, + size = ProfileImageSize, + ) + }, + isTitleCenter = tier.isEmpty() || favoriteTag.isEmpty(), + visibleHorizontalPadding = false, + rippleEnabled = rippleEnabled, + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/UserFollowingLayout.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/UserFollowingLayout.kt new file mode 100644 index 000000000..1fbd390e9 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/UserFollowingLayout.kt @@ -0,0 +1,70 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.content + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.R +import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.modifier.quackClickable + +private val ProfileImageSize: DpSize = DpSize(32.dp, 32.dp) + +@Composable +fun UserFollowingLayout( + modifier: Modifier = Modifier, + userId: Int, + profileImgUrl: String, + nickname: String, + favoriteTag: String, + tier: String, + isFollowing: Boolean, + onClickUserProfile: ((Int) -> Unit)? = null, + visibleTrailingButton: Boolean = false, + onClickTrailingButton: (Boolean) -> Unit, + isLoading: Boolean = false, +) { + BasicContentWithButtonLayout( + isLoading = isLoading, + modifier = modifier, + contentId = userId, + nickname = nickname, + description = tier + if (favoriteTag.isNotEmpty()) "· $favoriteTag" else "", + onClickLayout = onClickUserProfile, + trailingButton = { + QuackText( + modifier = it + .quackClickable( + onClick = { + onClickTrailingButton(!isFollowing) + }, + rippleEnabled = false, + ), + text = stringResource(id = if (isFollowing) R.string.following else R.string.follow), + typography = QuackTypography.Body2.change( + color = if (isFollowing) QuackColor.Gray1 else QuackColor.DuckieOrange, + ), + ) + }, + visibleTrailingButton = visibleTrailingButton, + leadingImageContent = { + QuackProfileImage( + modifier = it, + profileUrl = profileImgUrl, + size = ProfileImageSize, + ) + }, + isTitleCenter = tier.isEmpty() || favoriteTag.isEmpty(), + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/domain/DuckieTagAddBottomSheet.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/domain/DuckieTagAddBottomSheet.kt index b7e2a8aa1..476348a18 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/domain/DuckieTagAddBottomSheet.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/domain/DuckieTagAddBottomSheet.kt @@ -48,11 +48,13 @@ import team.duckie.app.android.common.compose.invisible import team.duckie.app.android.common.compose.rememberToast import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.compose.ui.ImeSpacer +import team.duckie.app.android.common.compose.ui.QuackDivider import team.duckie.app.android.common.compose.ui.icon.v1.ArrowSendId -import team.duckie.app.android.common.compose.ui.icon.v1.CloseId import team.duckie.app.android.common.compose.ui.quack.QuackNoUnderlineTextField +import team.duckie.app.android.common.compose.ui.quack.blackAndPrimaryColor import team.duckie.app.android.common.compose.ui.quack.todo.QuackCircleTag import team.duckie.app.android.common.kotlin.fastForEachIndexed +import team.duckie.app.android.common.kotlin.takeBy import team.duckie.app.android.domain.tag.model.Tag import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography @@ -64,6 +66,8 @@ import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackTitle2 import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +const val TagTitleMaxLength = 10 + /** * 태그를 추가할 수 있는 [ModalBottomSheetLayout] * primitive type 인 [String] 대신 [Tag] 를 직접 사용한다 @@ -198,7 +202,6 @@ private fun DuckieTagAddBottomSheetContent( QuackCircleTag( text = tag.name, isSelected = false, - trailingIconResId = QuackIcon.CloseId, ) { inputtedTags.remove(inputtedTags[index]) } @@ -215,17 +218,18 @@ private fun DuckieTagAddBottomSheetContent( } } } - - // TODO(riflockle7): 사용해도 괜찮을지 검토 필요 + QuackDivider() QuackNoUnderlineTextField( text = tagInput, - onTextChanged = { tagInput = it }, + onTextChanged = { + tagInput = it.takeBy(TagTitleMaxLength, tagInput) + }, placeholderText = stringResource(R.string.tag_add_manual_placeholder), - startPadding = 16.dp, - trailingEndPadding = 10.dp, trailingIcon = QuackIcon.ArrowSendId, trailingIconOnClick = ::updateTagInput, keyboardActions = KeyboardActions { updateTagInput() }, + trailingIconTint = blackAndPrimaryColor(tagInput).value, + paddingValues = PaddingValues(all = 16.dp), ) ImeSpacer() } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v1/DuckieIcon.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v1/DuckieIcon.kt index 4a512684c..96f9db0dc 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v1/DuckieIcon.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v1/DuckieIcon.kt @@ -8,8 +8,8 @@ package team.duckie.app.android.common.compose.ui.icon.v1 import team.duckie.app.android.common.compose.R -import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon import team.duckie.quackquack.material.icon.QuackIcon as QuackV2Icon +import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon val QuackV1Icon.Companion.DefaultProfile get() = R.drawable.ic_default_profile val QuackV1Icon.Companion.Notice get() = R.drawable.ic_notice_24 @@ -18,12 +18,12 @@ val QuackV1Icon.Companion.Create get() = R.drawable.ic_create_24 val QuackV1Icon.Companion.Crown get() = R.drawable.ic_crown_12 -val QuackV1Icon.Companion.Clock get() = R.drawable.ic_clock_12 - // Quack V2 의 QuackIcon 을 통해 Quack V1 의 drawable Resource 를 가져오는 확장 변수 val QuackV2Icon.ArrowBackId: Int get() = QuackV1Icon.ArrowBack.drawableId +val QuackV2Icon.ArrowRightId: Int get() = QuackV1Icon.ArrowRight.drawableId + val QuackV2Icon.ArrowSendId: Int get() = QuackV1Icon.ArrowSend.drawableId val QuackV2Icon.CloseId: Int get() = QuackV1Icon.Close.drawableId @@ -36,16 +36,27 @@ val QuackV2Icon.CameraId: Int get() = QuackV1Icon.Camera.drawableId val QuackV2Icon.TextLogoId: Int get() = QuackV1Icon.TextLogo.drawableId +val QuackV2Icon.ProfileId: Int get() = QuackV1Icon.Profile.drawableId + val QuackV2Icon.DefaultProfileId: Int get() = QuackV1Icon.DefaultProfile +val QuackV2Icon.CheckId: Int get() = QuackV1Icon.Check.drawableId + +val QuackV2Icon.CreateId: Int get() = QuackV1Icon.Create + +val QuackV2Icon.AreaId: Int get() = QuackV1Icon.Area.drawableId + val Int.toQuackV1Icon: QuackV1Icon? get() = when (this) { QuackV1Icon.ArrowBack.drawableId -> QuackV1Icon.ArrowBack + QuackV1Icon.ArrowRight.drawableId -> QuackV1Icon.ArrowRight QuackV1Icon.ArrowSend.drawableId -> QuackV1Icon.ArrowSend QuackV1Icon.Close.drawableId -> QuackV1Icon.Close QuackV1Icon.Search.drawableId -> QuackV1Icon.Search QuackV1Icon.More.drawableId -> QuackV1Icon.More QuackV1Icon.Camera.drawableId -> QuackV1Icon.Camera QuackV1Icon.TextLogo.drawableId -> QuackV1Icon.TextLogo + QuackV1Icon.Check.drawableId -> QuackV1Icon.Check + QuackV1Icon.Area.drawableId -> QuackV1Icon.Area else -> null } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v2/Clock.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v2/Clock.kt new file mode 100644 index 000000000..9e1f31294 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v2/Clock.kt @@ -0,0 +1,72 @@ +package team.duckie.app.android.common.compose.ui.icon.v2 + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import team.duckie.quackquack.material.icon.QuackIcon + +public val QuackIcon.Clock: ImageVector + get() { + if (_vector != null) { + return _vector!! + } + _vector = Builder( + name = "Vector", + defaultWidth = 12.0.dp, + defaultHeight = 12.0.dp, + viewportWidth = 12.0f, + viewportHeight = 12.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF222222)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(6.0f, 1.2f) + curveTo(4.727f, 1.2f, 3.5061f, 1.7057f, 2.6059f, 2.6059f) + curveTo(1.7057f, 3.5061f, 1.2f, 4.727f, 1.2f, 6.0f) + curveTo(1.2f, 7.273f, 1.7057f, 8.4939f, 2.6059f, 9.3941f) + curveTo(3.5061f, 10.2943f, 4.727f, 10.8f, 6.0f, 10.8f) + curveTo(7.273f, 10.8f, 8.4939f, 10.2943f, 9.3941f, 9.3941f) + curveTo(10.2943f, 8.4939f, 10.8f, 7.273f, 10.8f, 6.0f) + curveTo(10.8f, 4.727f, 10.2943f, 3.5061f, 9.3941f, 2.6059f) + curveTo(8.4939f, 1.7057f, 7.273f, 1.2f, 6.0f, 1.2f) + close() + moveTo(0.0f, 6.0f) + curveTo(0.0f, 2.6862f, 2.6862f, 0.0f, 6.0f, 0.0f) + curveTo(9.3138f, 0.0f, 12.0f, 2.6862f, 12.0f, 6.0f) + curveTo(12.0f, 9.3138f, 9.3138f, 12.0f, 6.0f, 12.0f) + curveTo(2.6862f, 12.0f, 0.0f, 9.3138f, 0.0f, 6.0f) + close() + moveTo(6.0f, 2.4f) + curveTo(6.1591f, 2.4f, 6.3117f, 2.4632f, 6.4243f, 2.5757f) + curveTo(6.5368f, 2.6883f, 6.6f, 2.8409f, 6.6f, 3.0f) + verticalLineTo(5.7516f) + lineTo(8.2242f, 7.3758f) + curveTo(8.3335f, 7.489f, 8.394f, 7.6405f, 8.3926f, 7.7978f) + curveTo(8.3912f, 7.9552f, 8.3281f, 8.1056f, 8.2169f, 8.2169f) + curveTo(8.1056f, 8.3281f, 7.9552f, 8.3912f, 7.7978f, 8.3926f) + curveTo(7.6405f, 8.394f, 7.489f, 8.3335f, 7.3758f, 8.2242f) + lineTo(5.5758f, 6.4242f) + curveTo(5.4633f, 6.3117f, 5.4f, 6.1591f, 5.4f, 6.0f) + verticalLineTo(3.0f) + curveTo(5.4f, 2.8409f, 5.4632f, 2.6883f, 5.5757f, 2.5757f) + curveTo(5.6883f, 2.4632f, 5.8409f, 2.4f, 6.0f, 2.4f) + close() + } + } + .build() + return _vector!! + } + +private var _vector: ImageVector? = null diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackNoUnderlineTextField.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackNoUnderlineTextField.kt index f9a8a1d01..37e206af5 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackNoUnderlineTextField.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackNoUnderlineTextField.kt @@ -8,6 +8,7 @@ package team.duckie.app.android.common.compose.ui.quack import androidx.annotation.DrawableRes +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,8 +21,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -54,6 +55,14 @@ import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText + +@NonRestartableComposable +@Composable +fun blackAndPrimaryColor(text: String) = animateColorAsState( + targetValue = if (text.isEmpty()) QuackColor.Unspecified.value else QuackColor.DuckieOrange.value, + label = "writeCommentButtonColor", +) @Composable fun QuackNoUnderlineTextField( @@ -62,10 +71,13 @@ fun QuackNoUnderlineTextField( onTextChanged: (text: String) -> Unit, placeholderText: String? = null, paddingValues: PaddingValues = PaddingValues( - vertical = 8.dp, - horizontal = 12.dp, + top = 8.dp, + bottom = 8.dp, + start = 12.dp, + end = 12.dp, ), startPadding: Dp = 0.dp, + leadingIconOnClick: (() -> Unit)? = null, @DrawableRes leadingIcon: Int? = null, trailingEndPadding: Dp = 0.dp, @DrawableRes trailingIcon: Int? = null, @@ -118,6 +130,7 @@ fun QuackNoUnderlineTextField( trailingIconOnClick = trailingIconOnClick, trailingIconSize = trailingIconSize, trailingIconTint = trailingIconTint, + leadingIconOnClick = leadingIconOnClick, ) }, ) @@ -135,13 +148,13 @@ private fun TextFieldDecoration( textField: @Composable () -> Unit, isPlaceholder: Boolean, placeholderText: String?, - @DrawableRes - leadingIcon: Int?, + @DrawableRes leadingIcon: Int?, trailingIcon: Int?, startPadding: Dp = 0.dp, leadingEndPadding: Dp = 0.dp, trailingStartPadding: Dp = 16.dp, trailingIconSize: Dp = 24.dp, + leadingIconOnClick: (() -> Unit)? = null, trailingIconOnClick: (() -> Unit)?, trailingIconTint: Color = Color.Unspecified, ) = with(TextFieldDecorationLayoutId) { @@ -155,7 +168,7 @@ private fun TextFieldDecoration( .size(DpSize(16.dp, 16.dp)) .padding(end = leadingEndPadding) .quackClickable( - onClick = trailingIconOnClick, + onClick = leadingIconOnClick, rippleEnabled = false, ) .padding(end = 16.dp), @@ -163,15 +176,14 @@ private fun TextFieldDecoration( ) } if (isPlaceholder && placeholderText != null) { - Text( + QuackText( modifier = Modifier .layoutId(PlaceholderId) .padding(start = startPadding), text = placeholderText, - style = QuackTypography.Body1.asComposeStyle().copy( - color = QuackColor.Gray2.value, + typography = QuackTypography.Body1.change( + color = QuackColor.Gray2, ), - maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackProfileImage.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackProfileImage.kt index d6039b704..11c1156ff 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackProfileImage.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackProfileImage.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import coil.ImageLoader import coil.compose.rememberAsyncImagePainter import team.duckie.app.android.common.compose.R +import team.duckie.app.android.common.kotlin.runIf import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.material.shape.SquircleShape @@ -60,12 +61,11 @@ fun QuackProfileImage( modifier = modifier .size(size) .clip(shape) - .quackClickable( - rippleEnabled = false, - ) { - if (onClick != null) { - onClick() - } + .runIf(onClick != null) { + quackClickable( + rippleEnabled = true, + onClick = onClick, + ) }, painter = asyncImagePainter, contentScale = contentScale, diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackCircleTag.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackCircleTag.kt index 0871986fd..a0b76a0fb 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackCircleTag.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackCircleTag.kt @@ -5,25 +5,30 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.common.compose.ui.quack.todo import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import team.duckie.app.android.common.compose.ui.icon.v1.toQuackV1Icon +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable fun QuackCircleTag( modifier: Modifier = Modifier, text: String, - trailingIconResId: Int? = null, isSelected: Boolean, onClick: (() -> Unit)? = null, ) { - team.duckie.quackquack.ui.component.QuackCircleTag( - modifier = modifier, + QuackTag( text = text, - trailingIcon = trailingIconResId?.toQuackV1Icon, - isSelected = isSelected, - onClick = onClick, - ) + style = QuackTagStyle.Outlined, + modifier = modifier.trailingIcon(OutlinedGroup.Close, onClick = onClick ?: {}), + selected = isSelected, + ) {} } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackDropDownCard.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackDropDownCard.kt new file mode 100644 index 000000000..7fe615f85 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackDropDownCard.kt @@ -0,0 +1,92 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.kotlin.runIf +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowDown +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackText + +/** + * 덕키에서 Drop Down 을 표시하는 컴포넌트를 구현합니다. + * [QuackDropDownCard] 는 다음과 같은 특징을 갖습니다. + * + * - 항상 trailing content 로 [QuackIcon.ArrowDown] 을 갖습니다. + * - 단순 rounding 형태의 Card 를 갖기 때문에 이름에 Card 가 추가됐습니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param text 표시할 텍스트 + * @param showBorder 테두리를 표시할지 여부 + * @param onClick 클릭했을 때 호출될 람다 + */ +@Composable +fun QuackDropDownCard( + modifier: Modifier = Modifier, + text: String, + showBorder: Boolean = true, + onClick: (() -> Unit)? = null, +): Unit = Row( + modifier = modifier + .clip(shape = RoundedCornerShape(size = 8.dp)) + .quackClickable( + onClick = onClick, + ) + .background( + color = QuackColor.White.value, + shape = RoundedCornerShape(size = 8.dp), + ) + .runIf(showBorder) { + quackBorder( + border = QuackBorder( + color = QuackColor.Gray3, + ), + shape = RoundedCornerShape(size = 8.dp), + ) + }, + verticalAlignment = Alignment.CenterVertically, +) { + QuackText( + modifier = Modifier.padding( + PaddingValues( + top = 8.dp, + bottom = 8.dp, + start = 12.dp, + end = 4.dp, + ), + ), + text = text, + typography = QuackTypography.Body1, + singleLine = true, + ) + QuackIcon( + modifier = Modifier + .size(DpSize(16.dp, 16.dp)) + .padding(PaddingValues(end = 8.dp)), + icon = OutlinedGroup.ArrowDown, + tint = QuackColor.Gray1, + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackErrorableTextField.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackErrorableTextField.kt new file mode 100644 index 000000000..85d31f76d --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackErrorableTextField.kt @@ -0,0 +1,202 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText + +sealed class QuackErrorableTextFieldState { + object Normal : QuackErrorableTextFieldState() + class Success(val successText: String) : QuackErrorableTextFieldState() + class Error(val errorText: String) : QuackErrorableTextFieldState() +} + +@Composable +fun QuackErrorableTextField( + modifier: Modifier = Modifier, + text: String, + onTextChanged: (text: String) -> Unit, + placeholderText: String, + maxLength: Int, + textFieldState: QuackErrorableTextFieldState, + imeAction: ImeAction = ImeAction.Done, + keyboardActions: KeyboardActions = KeyboardActions(), +) { + Column(modifier = modifier) { + BasicTextField( + modifier = modifier, + value = text, + onValueChange = onTextChanged, + keyboardOptions = KeyboardOptions( + imeAction = imeAction, + ), + keyboardActions = keyboardActions, + textStyle = QuackTypography.HeadLine2.asComposeStyle(), + singleLine = true, + ) { innerTextField -> + QuackTextFieldDecorationBox( + modifier = Modifier + .fillMaxWidth() + .bottomBorder( + strokeWidth = 1.dp, + textFieldState = textFieldState, + ), + leadingContent = { + Box( + modifier = Modifier + .weight(1f) + .padding(bottom = 8.dp), + ) { + if (text.isEmpty()) { + QuackText( + text = placeholderText, + typography = QuackTypography.HeadLine2.change( + color = QuackColor.Gray2, + ), + ) + } + innerTextField() + } + }, + trailingContent = { + Row( + modifier = Modifier.padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp), + ) { + QuackText( + text = "${text.length}", + typography = QuackTypography.Subtitle.change( + color = if (text.isEmpty()) { + QuackColor.Gray2 + } else { + QuackColor.Black + }, + ), + ) + QuackText( + text = "/", + typography = QuackTypography.Subtitle.change( + color = QuackColor.Gray2, + ), + ) + QuackText( + text = "$maxLength", + typography = QuackTypography.Subtitle.change( + color = QuackColor.Gray2, + ), + ) + } + }, + ) + } + Crossfade( + modifier = Modifier.padding(top = 4.dp), + targetState = textFieldState, + label = "", + ) { state -> + when (state) { + is QuackErrorableTextFieldState.Error -> { + QuackText( + text = state.errorText, + typography = QuackTypography.Body1.change( + color = QuackColor.Alert, + ), + ) + } + + is QuackErrorableTextFieldState.Success -> { + QuackText( + text = state.successText, + typography = QuackTypography.Body1.change( + color = QuackColor.Success, + ), + ) + } + + QuackErrorableTextFieldState.Normal -> {} + } + } + } +} + +@Composable +private fun QuackTextFieldDecorationBox( + modifier: Modifier = Modifier, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + leadingContent?.invoke() ?: Spacer(modifier = Modifier.weight(1f)) + trailingContent?.invoke() ?: Spacer(modifier = Modifier.weight(1f)) + } +} + +private fun Modifier.bottomBorder( + strokeWidth: Dp, + textFieldState: QuackErrorableTextFieldState, +) = composed( + factory = { + val color by animateColorAsState( + targetValue = when (textFieldState) { + is QuackErrorableTextFieldState.Error -> { + QuackColor.Alert.value + } + + QuackErrorableTextFieldState.Normal -> { + QuackColor.Gray2.value + } + + else -> QuackColor.Success.value + }, + label = "", + ) + val density = LocalDensity.current + val strokeWidthPx = density.run { strokeWidth.toPx() } + + Modifier.drawBehind { + val width = size.width + val height = size.height - strokeWidthPx / 2 + + drawLine( + color = color, + start = Offset(x = 0f, y = height), + end = Offset(x = width, y = height), + strokeWidth = strokeWidthPx, + ) + } + }, +) diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackLazyVerticalGridTag.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackLazyVerticalGridTag.kt index 9b2abd390..726689fce 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackLazyVerticalGridTag.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackLazyVerticalGridTag.kt @@ -5,16 +5,61 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class, ExperimentalQuackQuackApi::class) + package team.duckie.app.android.common.compose.ui.quack.todo +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.layout.LazyLayout +import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import team.duckie.app.android.common.compose.ui.icon.v1.toQuackV1Icon -import team.duckie.quackquack.ui.component.QuackTagType +import kotlinx.collections.immutable.ImmutableList +import team.duckie.app.android.common.kotlin.fastForEachIndexed +import team.duckie.app.android.common.kotlin.runtimeCheck +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +/** + * [LazyVerticalGrid] 형식으로 주어진 태그들을 배치합니다. + * 이 컴포넌트는 항상 상위 컴포저블의 가로 길이만큼 width 가 지정되고, + * 한 줄에 최대 [itemChunkedSize]개가 들어갈 수 있습니다. 또한 가로와 세로 스크롤을 모두 지원합니다. + * + * 퍼포먼스 측면에서 [LazyLayout] 를 사용하는 것이 좋지만, 덕키의 경우 + * 표시해야 하는 태그의 개수가 많지 않기 때문에 컴포저블을 직접 그려도 + * 성능에 중대한 영향을 미치지 않을 것으로 판단하여 [LazyColumn] 과 + * [Row] + [Modifier.horizontalScroll] 를 사용하여 구현하였습니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param contentPadding 이 컴포넌트의 광역에 적용될 [PaddingValues] + * @param title 상단에 표시될 제목. 만약 null 을 제공할 시 표시되지 않습니다. + * @param items 표시할 태그들의 제목. **중복되는 태그 제목은 허용하지 않습니다.** + * 이 항목은 바뀔 수 있으므로 [ImmutableList] 가 아닌 일반 [List] 로 받습니다. + * @param itemSelections 태그들의 선택 여부. + * 이 항목은 바뀔 수 있으므로 [ImmutableList] 가 아닌 [List] 로 받습니다. + * @param itemChunkedSize 한 칸에 들어갈 최대 아이템의 개수 + * @param horizontalSpace 아이템들의 가로 간격 + * @param verticalSpace 아이템들의 세로 간격 + * @param trailingIconResId trailingIcon 에 들어갈 ResourceId. 없을 시 아이콘이 없습니다. + * @param onClick 사용자가 태그를 클릭했을 때 호출되는 람다. + * 람다식의 인자로는 선택된 태그의 index 가 들어옵니다. + */ @Composable fun QuackLazyVerticalGridTag( modifier: Modifier = Modifier, @@ -25,19 +70,64 @@ fun QuackLazyVerticalGridTag( itemChunkedSize: Int, horizontalSpace: Dp = 8.dp, verticalSpace: Dp = 8.dp, - tagTypeResId: Int?, + trailingIcon: ImageVector? = null, + onTrailingClick: ((Int) -> Unit)? = null, onClick: (index: Int) -> Unit, ) { - team.duckie.quackquack.ui.component.QuackLazyVerticalGridTag( - modifier = modifier, - contentPadding = contentPadding, - title = title, - items = items, - itemSelections = itemSelections, - itemChunkedSize = itemChunkedSize, - horizontalSpace = horizontalSpace, - verticalSpace = verticalSpace, - tagType = QuackTagType.Circle(tagTypeResId?.toQuackV1Icon), - onClick = onClick, - ) + if (itemSelections != null) { + runtimeCheck(items.size == itemSelections.size) { + "The size of items and the size of itemsSelection must always be the same. " + + "[items.size (${items.size}) != itemsSelection.size (${itemSelections.size})]" + } + } + + val chunkedItems = remember(items) { + items.chunked(itemChunkedSize) + } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(contentPadding), + ) { + if (title != null) { + QuackText( + modifier = Modifier.padding(bottom = 12.dp), + text = title, + typography = QuackTypography.Title2, + singleLine = true, + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(horizontalSpace), + ) { + chunkedItems.fastForEachIndexed { rowIndex, items -> + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(state = rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(verticalSpace), + ) { + items.fastForEachIndexed { index, item -> + val currentIndex = rowIndex * itemChunkedSize + index + QuackTag( + text = item, + style = QuackTagStyle.Filled, + modifier = if (trailingIcon != null) { + Modifier.trailingIcon( + trailingIcon, + onClick = { onTrailingClick?.invoke(index) }, + ) + } else { + Modifier + }, + selected = itemSelections?.get(currentIndex) ?: false, + onClick = { onClick(index) }, + ) + } + } + } + } + } } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackReactionTextArea.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackReactionTextArea.kt index 6cc0d5a09..335c56054 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackReactionTextArea.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackReactionTextArea.kt @@ -40,7 +40,7 @@ private fun getQuackReactionTextAreaMeasurePolicy( with(QuackReactionTextAreaLayoutId) { val quizReviewPlaceHolderPlaceable = - measurables.getPlaceable(QuizReviewPlaceHolder, looseConstraints) + measurables.getPlaceable(QuizReviewPlaceHolder, extraLooseConstraints) val quizReviewTextAreaPlaceable = measurables.getPlaceable(QuizReviewTextArea, extraLooseConstraints) val reactionLimitTextPlaceable = @@ -50,12 +50,12 @@ private fun getQuackReactionTextAreaMeasurePolicy( width = constraints.maxWidth, height = constraints.maxHeight, ) { - quizReviewTextAreaPlaceable.place(x = 0, y = 0) + quizReviewTextAreaPlaceable.placeRelative(x = 0, y = 0) if (placeholderVisible) { - quizReviewPlaceHolderPlaceable.place(x = 0, y = 0) + quizReviewPlaceHolderPlaceable.placeRelative(x = 0, y = 0) } - reactionLimitTextPlaceable.place( - x = constraints.maxWidth - reactionLimitTextPlaceable.width, + reactionLimitTextPlaceable?.placeRelative( + x = quizReviewTextAreaPlaceable.width - reactionLimitTextPlaceable.width, y = quizReviewTextAreaPlaceable.height - reactionLimitTextPlaceable.height, ) } @@ -64,12 +64,15 @@ private fun getQuackReactionTextAreaMeasurePolicy( @Composable fun QuackReactionTextArea( + modifier: Modifier = Modifier, reaction: String, onReactionChanged: (String) -> Unit, maxLength: Int = RANKER_REACTION_MAX_LENGTH, + placeHolderText: String = RANKER_REACTION_PLACE_HOLDER, + visibleCurrentLength: Boolean = true, ) = with(QuackReactionTextAreaLayoutId) { Layout( - modifier = Modifier.height(100.dp), + modifier = modifier.height(100.dp), measurePolicy = getQuackReactionTextAreaMeasurePolicy( placeholderVisible = reaction.isEmpty(), ), @@ -91,23 +94,25 @@ fun QuackReactionTextArea( modifier = Modifier .layoutId(QuizReviewPlaceHolder) .padding(all = 16.dp), - text = RANKER_REACTION_PLACE_HOLDER, + text = placeHolderText, typography = QuackTypography.Body1.change( color = QuackColor.Gray2, ), ) - QuackText( - modifier = Modifier - .layoutId(ReactionLimitText) - .padding( - bottom = 12.dp, - end = 12.dp, + if (visibleCurrentLength) { + QuackText( + modifier = Modifier + .layoutId(ReactionLimitText) + .padding( + bottom = 12.dp, + end = 12.dp, + ), + text = "${reaction.length} / $RANKER_REACTION_MAX_LENGTH", + typography = QuackTypography.Body1.change( + color = QuackColor.Gray2, ), - text = "${reaction.length} / $RANKER_REACTION_MAX_LENGTH", - typography = QuackTypography.Body1.change( - color = QuackColor.Gray2, - ), - ) + ) + } }, ) } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSelectableImage.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSelectableImage.kt index 89a03bc45..40e169f5a 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSelectableImage.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSelectableImage.kt @@ -7,39 +7,133 @@ package team.duckie.app.android.common.compose.ui.quack.todo +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.DpSize -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSelectableImageType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import team.duckie.app.android.common.compose.ui.icon.v1.CheckId +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImageType.CheckOverlay +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImageType.TopEndCheckBox +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackRoundCheckBox +import team.duckie.app.android.common.kotlin.runIf +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.ui.QuackImage +/** + * 오른쪽 상단에 체크박스와 함께 이미지 혹은 [QuackIcon] 을 표시합니다. + * + * @param modifier 이 컴포저블에서 사용할 [Modifier] + * @param isSelected 현재 이미지가 선택됐는지 여부 + * @param src 표시할 리소스. 만약 null 이 들어온다면 리소스를 그리지 않습니다. + * @param size 리소스의 크기를 지정합니다. null 이 들어오면 기본 크기로 표시합니다. + * @param tint 적용할 틴트 값 + * @param shape 컴포넌트의 모양 + * @param selectableType selection 이 표시될 방식 + * @param rippleEnabled 클릭됐을 때 ripple 발생 여부 + * @param onClick 클릭됐을 때 실행할 람다식 + * @param contentScale 적용할 content scale 정책 + * @param contentDescription 이미지의 설명 + */ @Composable fun QuackSelectableImage( modifier: Modifier = Modifier, isSelected: Boolean, src: Any?, size: DpSize? = null, - tint: QuackColor? = null, + tint: QuackColor = QuackColor.Unspecified, shape: Shape = RectangleShape, + selectableType: QuackSelectableImageType = TopEndCheckBox, rippleEnabled: Boolean = true, onClick: (() -> Unit)? = null, contentScale: ContentScale = ContentScale.FillBounds, contentDescription: String? = null, ) { - team.duckie.quackquack.ui.component.QuackSelectableImage( + QuackSurface( modifier = modifier, - isSelected = isSelected, - src = src, - size = size, - tint = tint, shape = shape, - selectableType = QuackSelectableImageType.TopEndCheckBox, + border = BorderStroke(1.dp, QuackColor.Gray3.value) + .takeIf { isSelected } + .takeIf { selectableType == TopEndCheckBox }, rippleEnabled = rippleEnabled, onClick = onClick, - contentScale = contentScale, - contentDescription = contentDescription, + contentAlignment = Alignment.TopEnd, + ) { + QuackImage( + src = src, + modifier = Modifier + .zIndex(1f) + .runIf(size != null) { size(size!!) }, + tint = tint, + contentScale = contentScale, + contentDescription = contentDescription, + ) + + when (selectableType) { + TopEndCheckBox -> { + QuackRoundCheckBox( + modifier = Modifier + .padding(paddingValues = PaddingValues(all = 7.dp)) + .zIndex(2f), + checked = isSelected, + ) + } + + CheckOverlay -> { + AnimatedVisibility( + modifier = Modifier + .matchParentSize() + .zIndex(2f), + visible = isSelected, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = QuackColor.Dimmed.value), + contentAlignment = Alignment.Center, + ) { + QuackImage( + src = QuackIcon.CheckId, + modifier = Modifier.size(selectableType.size!!), + tint = selectableType.tint!!, + ) + } + } + } + } + } +} + +/** + * [QuackSelectableImage] 에서 selection 이 표시될 방식을 나타냅니다. + * + * @property TopEndCheckBox 오른쪽 상단에 [QuackRoundCheckBox] 로 표시 + * @property CheckOverlay 이미지 전체에 [QuackIcon.Check] 로 오버레이 표시 및 + * [QuackColor.Dimmed] 로 dimmed 처리 + * + * @param size 만약 [CheckOverlay] 방식일 때 [QuackIcon.Check] 의 사이즈 + * @param tint 만약 [CheckOverlay] 방식일 때 [QuackIcon.Check] 의 틴트 + */ +sealed class QuackSelectableImageType( + internal val size: DpSize? = null, + internal val tint: QuackColor? = null, +) { + object TopEndCheckBox : QuackSelectableImageType() + object CheckOverlay : QuackSelectableImageType( + size = DpSize(width = 28.dp, height = 28.dp), + tint = QuackColor.White, ) } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSingeLazyRowTag.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSingeLazyRowTag.kt index b70f5194f..66d8fc550 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSingeLazyRowTag.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSingeLazyRowTag.kt @@ -5,39 +5,125 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.common.compose.ui.quack.todo +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import team.duckie.app.android.common.compose.ui.icon.v1.toQuackV1Icon -import team.duckie.quackquack.ui.component.QuackTagType +import kotlinx.collections.immutable.ImmutableList +import team.duckie.app.android.common.kotlin.runtimeCheck +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +/** + * [LazyRow] 형식으로 주어진 태그들을 **한 줄로** 배치합니다. + * 이 컴포넌트는 항상 상위 컴포저블의 가로 길이만큼 width 가 지정됩니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param contentPadding 이 컴포넌트의 광역에 적용될 [PaddingValues] + * @param title 상단에 표시될 제목. 만약 null 을 제공할 시 표시되지 않습니다. + * @param items 표시할 태그들의 제목. **중복되는 태그 제목은 허용하지 않습니다.** + * 이 항목은 바뀔 수 있으므로 [ImmutableList] 가 아닌 일반 [List] 로 받습니다. + * @param itemSelections 태그들의 선택 여부. + * 이 항목은 바뀔 수 있으므로 [ImmutableList] 가 아닌 [List] 로 받습니다. + * @param horizontalSpace 아이템들의 가로 간격 + * @param tagType [QuackLazyVerticalGridTag] 에서 표시할 태그의 타입을 지정합니다. + * 여러 종류의 태그가 [QuackLazyVerticalGridTag] 으로 표시될 수 있게 태그의 타입을 따로 받습니다. + * @param key a factory of stable and unique keys representing the item. Using the same key + * for multiple items in the list is not allowed. Type of the key should be saveable + * via Bundle on Android. If null is passed the position in the list will represent the key. + * When you specify the key the scroll position will be maintained based on the key, which + * means if you add/remove items before the current visible item the item with the given key + * will be kept as the first visible one. + * @param contentType a factory of the content types for the item. The item compositions of + * the same type could be reused more efficiently. Note that null is a valid type and items of such + * type will be considered compatible. + * @param onClick 사용자가 태그를 클릭했을 때 호출되는 람다. + * 람다식의 인자로는 선택된 태그의 index 가 들어옵니다. + */ @Composable -fun QuackSingeLazyRowTag( +fun QuackOutLinedSingeLazyRowTag( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(all = 0.dp), title: String? = null, items: List, itemSelections: List? = null, horizontalSpace: Dp = 8.dp, - tagTypeResId: Int?, + trailingIcon: ImageVector? = null, key: ((index: Int, item: String) -> Any)? = null, contentType: (index: Int, item: String) -> Any? = { _, _ -> null }, + onTrailingIconClick: ((index: Int) -> Unit)? = null, onClick: (index: Int) -> Unit, ) { - team.duckie.quackquack.ui.component.QuackSingeLazyRowTag( - modifier = modifier, - contentPadding = contentPadding, - title = title, - items = items, - itemSelections = itemSelections, - horizontalSpace = horizontalSpace, - tagType = QuackTagType.Circle(tagTypeResId?.toQuackV1Icon), - key = key, - contentType = contentType, - onClick = onClick, - ) + if (itemSelections != null) { + runtimeCheck( + value = items.size == itemSelections.size, + ) { + "The size of items and the size of itemsSelection must always be the same. " + + "[items.size (${items.size}) != itemsSelection.size (${itemSelections.size})]" + } + } + + val layoutDirection = LocalLayoutDirection.current + + Column(modifier = modifier.fillMaxWidth()) { + if (title != null) { + team.duckie.quackquack.ui.QuackText( + modifier = Modifier.padding( + start = contentPadding.calculateStartPadding(layoutDirection), + end = contentPadding.calculateEndPadding(layoutDirection), + bottom = 12.dp, + ), + text = title, + typography = QuackTypography.Title2, + singleLine = true, + ) + } + + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(horizontalSpace), + ) { + itemsIndexed( + items = items, + key = key, + contentType = contentType, + ) { index, item -> + QuackTag( + text = item, + style = QuackTagStyle.Outlined, + modifier = if (trailingIcon != null) { + Modifier.trailingIcon( + trailingIcon, + onClick = { onTrailingIconClick?.invoke(index) }, + ) + } else { + Modifier + }, + selected = itemSelections?.get(index) ?: false, + onClick = { + onClick(index) + }, + ) + } + } + } } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSurface.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSurface.kt new file mode 100644 index 000000000..c3416a8f9 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSurface.kt @@ -0,0 +1,112 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackAnimationSpec +import team.duckie.app.android.common.kotlin.runIf +import team.duckie.quackquack.animation.animateQuackColorAsState +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.quackClickable + +/** + * 모든 Quack 컴포넌트에서 최하위로 사용되는 컴포넌트입니다. + * 컴포넌트의 기본 모양을 정의합니다. + * + * **애니메이션 가능한 모든 요소들에는 자동으로 애니메이션이 적용됩니다.** + * animationSpec 으로는 항상 [QuackAnimationSpec] 을 사용합니다. + * + * @param modifier 컴포저블에 적용할 [Modifier]. + * 기본값은 수정 없는 본질의 Modifier 입니다. + * @param shape 컴포저블의 [Shape]. 기본값은 [RectangleShape] 입니다. + * @param backgroundColor 컴포저블의 배경 색상. + * 기본값은 정의되지 않은 색상인 [QuackColor.Unspecified] 입니다. + * @param border 컴포저블의 테두리. + * null 이 입력된다면 테두리를 설정하지 않습니다. 기본값은 null 입니다. + * @param elevation 컴포저블의 그림자 고도. + * 기본값은 0 입니다. 즉, 그림자를 사용하지 않습니다. + * @param rippleEnabled 컴포저블이 클릭됐을 때 리플 효과를 적용할지 여부. + * 기본값은 true 입니다. + * @param rippleColor 컴포저블이 클릭됐을 때 리플 효과의 색상. + * 기본값은 정해지지 않은 색상인 [QuackColor.Unspecified] 입니다. + * [rippleEnabled] 이 켜져 있을 때만 사용됩니다. + * @param onClick 컴포저블이 클릭됐을 때 실행할 람다식. + * null 이 입력된다면 클릭 이벤트를 추가하지 않습니다. + * 기본값은 null 입니다. 즉, 클릭 이벤트를 추가하지 않습니다. + * @param contentAlignment 컴포저블의 정렬 상태. 기본값은 Center 입니다. + * @param propagateMinConstraints 최소 제약 조건을 전파할지 여부. 기본값은 false 입니다. + * @param content 표시할 컴포저블. [BoxScope] 를 receive 로 받습니다. + */ +// TODO: Modifier.quackSurface 로 변경 +// @NonRestartableComposable; 여기서 사용하는 Box 는 inline 됨 +@Composable +fun QuackSurface( + modifier: Modifier = Modifier, + shape: Shape = RectangleShape, + backgroundColor: QuackColor = QuackColor.Unspecified, + border: BorderStroke? = null, + elevation: Dp = 0.dp, + rippleEnabled: Boolean = true, + rippleColor: QuackColor = QuackColor.Unspecified, + onClick: (() -> Unit)? = null, + contentAlignment: Alignment = Alignment.Center, + propagateMinConstraints: Boolean = false, + content: @Composable BoxScope.() -> Unit, +) { + val backgroundColorAnimation by animateQuackColorAsState( + targetValue = backgroundColor, + ) + + Box( + modifier = modifier + .shadow( + elevation = elevation, + shape = shape, + clip = false, + ) + .clip( + shape = shape, + ) + .background( + color = backgroundColorAnimation.value, + shape = shape, + ) + .quackClickable( + onClick = onClick, + rippleEnabled = rippleEnabled, + rippleColor = rippleColor, + ) + .runIf(border != null) { + border( + border = border!!, + shape = shape, + ) + } + .animateContentSize( + animationSpec = QuackAnimationSpec(), + ), + contentAlignment = contentAlignment, + propagateMinConstraints = propagateMinConstraints, + content = content, + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackTopAppBar.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackTopAppBar.kt index 7128f7dc6..accd645d5 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackTopAppBar.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackTopAppBar.kt @@ -7,44 +7,353 @@ package team.duckie.app.android.common.compose.ui.quack.todo +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.icon.v1.TextLogoId import team.duckie.app.android.common.compose.ui.icon.v1.toQuackV1Icon +import team.duckie.app.android.common.compose.util.expendedQuackClickable +import team.duckie.app.android.common.kotlin.runtimeCheck +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon -/** 덕키에서 사용하는 TopAppBar */ +/** + * 덕키의 Top Navigation Bar 를 그립니다. + * [QuackTopAppBar] 는 몇몇 중요한 특징이 있습니다. + * + * - 최소 하나의 값이 제공돼야 합니다. + * - 항상 상위 컴포저블의 가로 길이에 꽉차게 그립니다. + * - [showLogoAtCenter] 이 true 라면 [centerText] 값은 무시됩니다. + * 로고 리소스로는 [QuackIcon.TextLogoId] 를 사용합니다. + * - [centerText] 값이 있다면 [showLogoAtCenter] 값은 무시됩니다. + * 또한 [centerText] 는 trailing content 로 아이콘을 배치할 수 있습니다. + * - [trailingExtraIcon] 과 [trailingIcon] 이 하나라도 들어왔다면 [trailingText] 는 무시되며, + * `[trailingExtraIcon] [trailingIcon]` 순서로 배치됩니다. + * - [trailingText] 값이 입력되면 [trailingExtraIcon] 과 [trailingIcon] 값은 무시됩니다. + * - [trailingContent] 이 있다면 [trailingIcon], [trailingExtraIcon], [trailingText], + * [onTrailingIconClick], [onTrailingExtraIconClick], [onTrailingTextClick] 값은 무시됩니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param leadingIcon leading content 로 배치할 아이콘 resId + * @param leadingText leading content 로 배치할 텍스트. 선택적으로 값을 받습니다. + * @param onLeadingIconClick [leadingIcon] 이 클릭됐을 때 실행될 람다 + * @param showLogoAtCenter center content 로 덕키의 로고를 배치할지 여부 + * @param centerText center content 에 로고 대신에 표시할 텍스트 + * @param centerTextTrailingIcon [centerText] 의 trailing content 로 배치할 아이콘 resId + * @param onCenterClick center content 가 클릭됐을 때 실행될 람다 + * @param trailingContent trailing content 로 배치할 컴포넌트 + * @param trailingIcon trailing content 로 배치할 아이콘 + * @param trailingExtraIcon trailing content 에 추가로 배치할 아이콘 + * @param trailingText trailing content 에 배치할 텍스트 + * @param onTrailingIconClick [trailingIcon] 이 클릭됐을 때 실행될 람다 + * @param onTrailingExtraIconClick [trailingExtraIcon] 이 클릭됐을 때 실행될 람다 + * @param onTrailingTextClick [trailingText] 가 클릭됐을 때 실행될 람다 + */ @Composable fun QuackTopAppBar( modifier: Modifier = Modifier, - leadingIconResId: Int? = null, + leadingIcon: ImageVector? = null, leadingText: String? = null, onLeadingIconClick: (() -> Unit)? = null, showLogoAtCenter: Boolean? = null, centerText: String? = null, - centerTextTrailingIconResId: Int? = null, + centerTextTrailingIcon: ImageVector? = null, onCenterClick: (() -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null, - trailingIconResId: Int? = null, - trailingExtraIconResId: Int? = null, + trailingIcon: ImageVector? = null, + trailingExtraIcon: ImageVector? = null, trailingText: String? = null, onTrailingIconClick: (() -> Unit)? = null, onTrailingExtraIconClick: (() -> Unit)? = null, onTrailingTextClick: (() -> Unit)? = null, ) { - team.duckie.quackquack.ui.component.QuackTopAppBar( - modifier = modifier, - leadingIcon = leadingIconResId?.toQuackV1Icon, - leadingText = leadingText, - onLeadingIconClick = onLeadingIconClick, - showLogoAtCenter = showLogoAtCenter, - centerText = centerText, - centerTextTrailingIcon = centerTextTrailingIconResId?.toQuackV1Icon, - onCenterClick = onCenterClick, - trailingContent = trailingContent, - trailingIcon = trailingIconResId?.toQuackV1Icon, - trailingExtraIcon = trailingExtraIconResId?.toQuackV1Icon, - trailingText = trailingText, - onTrailingIconClick = onTrailingIconClick, - onTrailingExtraIconClick = onTrailingExtraIconClick, - onTrailingTextClick = onTrailingTextClick, + runtimeCheck( + leadingIcon != null || + leadingText != null || + onLeadingIconClick != null || + showLogoAtCenter != null || + centerText != null || + centerTextTrailingIcon != null || + onCenterClick != null || + trailingContent != null || + trailingIcon != null || + trailingExtraIcon != null || + trailingText != null || + onTrailingIconClick != null || + onTrailingExtraIconClick != null || + onTrailingTextClick != null, + ) { + "At least one param setting is required." + } + + if (trailingContent != null) { + runtimeCheck( + trailingIcon == null && trailingExtraIcon == null && trailingText == null && + onTrailingIconClick == null && onTrailingExtraIconClick == null && + onTrailingTextClick == null, + ) { + "trailingContent 가 입력되었을 때는 다른 trailing content 인자들을 이용하실 수 없습니다." + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .background(color = QuackTopAppBarDefaults.BackgroundColor.value), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + QuackTopAppBarDefaults.LeadingContent( + icon = leadingIcon, + text = leadingText, + onIconClick = onLeadingIconClick, + ) + QuackTopAppBarDefaults.CenterContent( + showLogo = showLogoAtCenter, + text = centerText, + textTrailingIcon = centerTextTrailingIcon, + onClick = onCenterClick, + ) + // https://github.com/duckie-team/quack-quack-android/issues/412 + // TrailingContent content 가 없어도 width 를 차지함 + if (trailingContent != null) { + trailingContent() + } else { + QuackTopAppBarDefaults.TrailingContent( + icon = trailingIcon, + extraIcon = trailingExtraIcon, + text = trailingText, + onIconClick = onTrailingIconClick, + onExtraIconClick = onTrailingExtraIconClick, + onTextClick = onTrailingTextClick, + ) + } + } +} + +/** + * QuackTopAppBar 를 그리기 위한 리소스들을 정의합니다. + */ +private object QuackTopAppBarDefaults { + val BackgroundColor = QuackColor.White + + private val CenterTypography = QuackTypography.Body1 + private val TrailingTypography = QuackTypography.Subtitle.change( + color = QuackColor.Gray2, ) + + private val LogoIcon: QuackV1Icon = QuackIcon.TextLogoId.toQuackV1Icon!! + private val LogoIconSize = DpSize( + width = 72.dp, + height = 24.dp, + ) + private val LogoPadding = PaddingValues( + vertical = 12.dp, + ) + + private val CenterTextPadding = PaddingValues( + vertical = 15.dp, + ) + private val CenterIconTint = QuackColor.Gray1 + + /** + * 모든 영역에서 사용되는 공통 아이콘 사이즈 + */ + private val IconSize: Dp = 24.dp + + /** + * leading content 를 배치합니다. + * + * @param icon 배치할 아이콘 + * @param text 배치할 텍스트. 선택적으로 값을 받습니다. + * @param onIconClick [icon] 이 클릭됐을 때 실행될 람다 + */ + @Composable + fun LeadingContent( + icon: ImageVector?, + text: String? = null, + onIconClick: (() -> Unit)? = null, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { + QuackIcon( + modifier = Modifier + .expendedQuackClickable( + rippleEnabled = true, + onClick = onIconClick, + ) + .size(DpSize(44.dp, 44.dp)) + .padding(10.dp), + icon = icon, + ) + } + text?.let { + // TODO: 최대 width 처리 + // 아마 커스텀 레이아웃 필요할 듯 + QuackText( + text = text, + typography = QuackTypography.HeadLine2, + singleLine = true, + ) + } + } + } + + /** + * center content 를 배치합니다. + * + * - [showLogo] 이 true 라면 [text] 값은 무시됩니다. + * 로고 리소스로는 [QuackIcon.TextLogoId] 를 사용합니다. + * - [text] 값이 있다면 [showLogo] 값은 무시됩니다. + * 또한 [text] 는 trailing icon 을 가질 수 있습니다. + * + * @param showLogo 덕키의 로고를 배치할지 여부 + * @param text 로고 대신에 표시할 텍스트 + * @param textTrailingIcon [text] 의 trailing content 로 표시될 아이콘 + * @param onClick center content 가 클릭됐을 때 실행될 람다 + */ + @Composable + fun CenterContent( + showLogo: Boolean? = null, + text: String? = null, + textTrailingIcon: ImageVector? = null, + onClick: (() -> Unit)? = null, + ) { + if (showLogo == true) { + runtimeCheck( + value = text == null, + ) { + "로고와 텍스트를 동시에 표시할 수 없습니다" + } + } + Row( + modifier = Modifier.quackClickable( + rippleEnabled = false, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showLogo == true) { + QuackImage( + modifier = Modifier + .size(LogoIconSize) + .padding(LogoPadding), + src = LogoIcon, + ) + } else { + text?.let { + QuackText( + modifier = Modifier.padding( + paddingValues = CenterTextPadding, + ), + text = text, + typography = CenterTypography, + singleLine = true, + ) + } + textTrailingIcon?.let { + QuackIcon( + icon = textTrailingIcon, + tint = CenterIconTint, + size = 24.dp, + ) + } + } + } + } + + /** + * trailing content 를 그립니다. + * + * - [extraIcon] 과 [icon] 이 하나라도 들어왔다면 [text] 는 무시되며, + * `[extraIcon] [icon]` 순서로 배치됩니다. + * - [text] 값이 입력되면 [extraIcon] 과 [icon] 값은 무시됩니다. + * + * @param icon 배치할 아이콘 + * @param extraIcon 추가로 배치할 아이콘 + * @param text 배치할 텍스트 + * @param onIconClick [icon] 이 클릭됐을 때 실행될 람다 + * @param onExtraIconClick [extraIcon] 이 클릭됐을 때 실행될 람다 + * @param onTextClick [text] 가 클릭됐을 때 실행될 람다 + */ + @Composable + fun TrailingContent( + icon: ImageVector? = null, + extraIcon: ImageVector? = null, + text: String? = null, + onIconClick: (() -> Unit)? = null, + onExtraIconClick: (() -> Unit)? = null, + onTextClick: (() -> Unit)? = null, + ) { + if (icon != null || extraIcon != null) { + runtimeCheck( + value = text == null, + ) { + "아이콘과 텍스트를 동시에 표시할 수 없습니다" + } + } + if (text != null) { + runtimeCheck( + value = icon == null && extraIcon == null, + ) { + "텍스트와 아이콘을 동시에 표시할 수 없습니다" + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + text?.let { + QuackText( + modifier = Modifier + .expendedQuackClickable( + rippleEnabled = true, + onClick = onTextClick, + ), + text = text, + typography = TrailingTypography, + singleLine = true, + ) + } + extraIcon?.let { + QuackIcon( + icon = extraIcon, + modifier = Modifier + .expendedQuackClickable( + rippleEnabled = true, + onClick = onExtraIconClick, + ), + ) + } + icon?.let { + QuackIcon( + icon = icon, + modifier = Modifier + .expendedQuackClickable( + rippleEnabled = true, + onClick = onIconClick, + ), + size = IconSize, + ) + } + } + } } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/AnimationSpec.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/AnimationSpec.kt new file mode 100644 index 000000000..01a3fbf1d --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/AnimationSpec.kt @@ -0,0 +1,64 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo.animation + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.DurationBasedAnimationSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.SnapSpec +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Stable + +/** + * 꽥꽥에서 사용할 [AnimationSpec] 에 대한 정보 + */ +object QuackAnimationSpec { + /** + * 일부 환경에서는 애니메이션이 없이 진행돼야 할 때도 있습니다. + * 이 값을 true 로 설정하면 모든 애니메이션을 무시합니다. + * + * **이 값의 변경은 모든 애니메이션에 영향을 미치므로 신중하게 사용해야 합니다.** + */ + var snapMode: Boolean = false + + /** + * 꽥꽥에서 사용할 [애니메이션의 기본 스팩][AnimationSpec] + * + * @return 덕키에서 사용할 [AnimationSpec]. [snapMode] 에 따라 반환값이 달라집니다. + * false 라면 덕키에서 사용하는 애니메이션 스팩인 [TweenSpec] 이 반환되고, + * true 라면 [SnapSpec] 이 반환됩니다. + * + * @see snapMode + */ + operator fun invoke(): DurationBasedAnimationSpec = when (snapMode) { + true -> snap() + else -> tween( + durationMillis = 250, + easing = LinearEasing, + ) + } +} + +/** + * [QuackAnimationSpec.snapMode] 대신에 한 번만 선택적으로 애니메이션 여부를 + * 결정하기 위해 사용할 수 있습니다. + * + * @param useAnimation 애니메이션을 사용할지 여부 + * + * @return [useAnimation] 여부에 따른 [DurationBasedAnimationSpec] + */ +@Suppress("FunctionName") +@Stable +public fun QuackOptionalAnimationSpec( + useAnimation: Boolean, +): DurationBasedAnimationSpec = when (useAnimation) { + true -> QuackAnimationSpec() + else -> snap() +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/toggle.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/toggle.kt new file mode 100644 index 000000000..0c37863e5 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/toggle.kt @@ -0,0 +1,585 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/master/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo.animation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathMeasure +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSurface +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackToggleIconSize.Compact +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackToggleIconSize.Normal +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackToggleIconSize.Small +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText +import kotlin.math.floor + +/** + * [QuackToggleButton] 에서 표시할 아이콘의 사이즈를 정의합니다. + * + * @property Normal 보통 사이즈로 표시합니다. (24 dp) + * @property Small 약간 축소된 사이즈로 표시합니다. (18 dp) + * @property Compact 많이 축소된 사이즈로 표시합니다. (14 dp) + */ +enum class QuackToggleIconSize( + val size: DpSize, +) { + Normal( + size = DpSize( + width = 24.dp, + height = 24.dp, + ), + ), + Small( + size = DpSize( + width = 18.dp, + height = 18.dp, + ), + ), + Compact( + size = DpSize( + width = 14.dp, + height = 14.dp, + ), + ), +} + +/** + * QuackToggle 을 그리는데 필요한 리소스를 구성합니다. + */ +private object QuackToggleDefaults { + // Copied from AOSP + object DrawConstaints { + const val TransitionLabel = "CheckTransition" + const val BoxOutDuration = 100 + const val StopLocation = 0.5f + + val StrokeWidth = 1.5.dp + const val CheckCrossX = 0.4f + const val CheckCrossY = 0.7f + const val LeftX = 0.25f + const val LeftY = 0.55f + const val RightX = 0.75f + const val RightY = 0.35f + } + + object RoundCheck { + /** + * 주어진 상황에 맞는 테두리를 계산합니다. + * + * @param isChecked 현재 선택된 상태인지 여부 + * + * @return [isChecked] 여부에 따른 [QuackBorder] + */ + @Stable + fun borderFor( + isChecked: Boolean, + ) = BorderStroke( + width = 1.dp, + color = when (isChecked) { + true -> QuackColor.DuckieOrange.value + else -> QuackColor.White.value + }, + ) + + /** + * 주어진 상황에 맞는 배경 색상을 계산합니다. + * + * @param isChecked 현재 선택된 상태인지 여부 + * + * @return [isChecked] 여부에 따른 [QuackColor] + */ + @Stable + fun backgroundColorFor( + isChecked: Boolean, + ) = when (isChecked) { + true -> QuackColor.DuckieOrange + else -> QuackColor.Black.change( + alpha = 0.2f, + ) + } + + val ContainerSize = DpSize( + width = 24.dp, + height = 24.dp, + ) + val ContainerShape = CircleShape + + val CheckColor = QuackColor.White + val CheckSize = DpSize( + width = 18.dp, + height = 18.dp, + ) + } + + object SquareCheck { + /** + * 주어진 상황에 맞는 배경 색상을 계산합니다. + * + * @param isChecked 현재 선택된 상태인지 여부 + * + * @return [isChecked] 여부에 따른 [QuackColor] + */ + @Stable + fun backgroundColorFor( + isChecked: Boolean, + ) = when (isChecked) { + true -> QuackColor.DuckieOrange + else -> QuackColor.Gray3 + } + + val ContainerSize = DpSize( + width = 24.dp, + height = 24.dp, + ) + val ContainerShape = RoundedCornerShape( + size = 4.dp, + ) + + val CheckColor = QuackColor.White + val CheckSize = DpSize( + width = 18.dp, + height = 18.dp, + ) + } + + object ToggleButton { + val IconSize = Small + val Typography = QuackTypography.Body2.change( + color = QuackColor.Gray1, + ) + + /** + * ``` + * [Icon][Typography] + * ``` + * + * 에서 `Icon` 과 `Typography` 간의 사이 간격을 나타냅니다. + */ + val ItemSpacedBy = 4.dp + } +} + +/** + * 덕키의 원형 CheckBox 를 구현합니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param checked 체크되었는지 여부 + * @param onClick 체크시 호출되는 콜백 + */ +@Composable +fun QuackRoundCheckBox( + modifier: Modifier = Modifier, + checked: Boolean, + onClick: (() -> Unit)? = null, +): Unit = with( + receiver = QuackToggleDefaults.RoundCheck, +) { + QuackSurface( + modifier = modifier.size( + size = ContainerSize, + ), + shape = ContainerShape, + backgroundColor = backgroundColorFor( + isChecked = checked, + ), + border = borderFor( + isChecked = checked, + ), + onClick = onClick, + ) { + Check( + value = ToggleableState( + value = checked, + ), + checkColor = CheckColor, + size = CheckSize, + ) + } +} + +/** + * 덕키의 원형 CheckBox 를 구현합니다. + * [QuackRoundCheckBox] 보다 작습니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param checked 체크되었는지 여부 + * @param checkedText 체크시 하단에 표시할 텍스트 + * @param onClick 체크시 호출되는 콜백 + */ +@Composable +public fun QuackSmallRoundCheckBox( + modifier: Modifier = Modifier, + checked: Boolean, + checkedText: String, + onClick: (() -> Unit)? = null, +): Unit = with(QuackToggleDefaults.RoundCheck) { + AnimatedContent( + targetState = checked, + label = "AnimatedContent", + ) { showUnderText -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + QuackSurface( + modifier = modifier.size(DpSize(width = 18.dp, height = 18.dp)), + shape = ContainerShape, + backgroundColor = backgroundColorFor( + isChecked = checked, + ), + border = borderFor( + isChecked = checked, + ), + onClick = onClick, + ) { + Check( + value = ToggleableState( + value = checked, + ), + checkColor = CheckColor, + size = DpSize(width = 12.dp, height = 12.dp), + ) + } + if (showUnderText) { + QuackText( + modifier = Modifier.padding(top = 2.dp), + text = checkedText, + typography = QuackTypography.Body3.change(color = QuackColor.DuckieOrange), + ) + } + } + } +} + +/** + * 덕키의 사각형 CheckBox 를 구현합니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param checked 체크되었는지 여부 + * @param onClick 체크시 호출되는 콜백 + */ +@Composable +public fun QuackSquareCheckBox( + modifier: Modifier = Modifier, + checked: Boolean, + onClick: (() -> Unit)? = null, +): Unit = with( + receiver = QuackToggleDefaults.SquareCheck, +) { + QuackSurface( + modifier = modifier.size( + size = ContainerSize, + ), + shape = ContainerShape, + backgroundColor = backgroundColorFor( + isChecked = checked, + ), + onClick = onClick, + ) { + Check( + value = ToggleableState( + value = checked, + ), + checkColor = CheckColor, + size = CheckSize, + ) + } +} + +/** + * Checked 여부에 따라 조건에 맞는 아이콘을 표시합니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param checkedIcon 체크 상태에서 표시할 아이콘 + * @param uncheckedIcon 체크 상태가 아닐 때 표시할 아이콘 + * @param iconSize 아이콘을 표시할 크기. + * 기본값은 [QuackToggleIconSize.Normal] 이며, [trailingText] 이 존재할 때는 + * [QuackToggleIconSize.Small] 로 강제됩니다. + * @param checked 현재 체크 상태에 있는지 여부 + * @param trailingText trailing content 로 배치될 텍스트. + * 만약 null 이 입력될 시 trailing content 를 배치하지 않습니다. + * @param onClick 아이콘을 클릭했을 때 실행될 람다 + */ +@Composable +fun QuackToggleButton( + modifier: Modifier = Modifier, + checkedIcon: QuackIcon, + uncheckedIcon: QuackIcon, + iconSize: QuackToggleIconSize = Normal, + checked: Boolean, + trailingText: String? = null, + onClick: (() -> Unit)? = null, +): Unit = with( + receiver = QuackToggleDefaults.ToggleButton, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy( + space = ItemSpacedBy, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + QuackImage( + src = when (checked) { + true -> checkedIcon + else -> uncheckedIcon + }, + modifier = Modifier + .quackClickable( + rippleEnabled = false, + onClick = onClick, + ) + .size((iconSize.takeIf { trailingText == null } ?: IconSize).size), + ) + trailingText?.let { + QuackText( + text = trailingText, + typography = Typography, + singleLine = true, + ) + } + } +} + +/** + * [Canvas] 에 Check 모양을 그립니다. + * + * animationSpec 으로 항상 [QuackAnimationSpec] 을 사용합니다. + * + * @param value 현재 토글 상태를 의미하는 [ToggleableState] + * @param checkColor Check 색상 + * @param size Check 의 크기 + */ +@Composable +private fun Check( + value: ToggleableState, + checkColor: QuackColor, + size: DpSize, +): Unit = with( + receiver = QuackToggleDefaults.DrawConstaints, +) { + val transition = updateTransition( + targetState = value, + label = TransitionLabel, + ) + val checkDrawFraction by transition.animateFloat( + transitionSpec = { + QuackAnimationSpec() + }, + label = TransitionLabel, + ) { toggleableState -> + when (toggleableState) { + ToggleableState.On -> 1f + ToggleableState.Off -> 0f + ToggleableState.Indeterminate -> 1f + } + } + val checkCenterGravitationShiftFraction by transition.animateFloat( + transitionSpec = { + when { + initialState == ToggleableState.Off -> snap() + targetState == ToggleableState.Off -> snap( + delayMillis = BoxOutDuration, + ) + + else -> QuackAnimationSpec() + } + }, + label = TransitionLabel, + ) { toggleableState -> + when (toggleableState) { + ToggleableState.On -> 0f + ToggleableState.Off -> 0f + ToggleableState.Indeterminate -> 1f + } + } + val checkCache = remember { + CheckDrawingCache() + } + Canvas( + modifier = Modifier + .wrapContentSize( + align = Alignment.Center, + ) + .requiredSize( + size = size, + ), + ) { + val strokeWidthPx = floor( + x = StrokeWidth.toPx(), + ) + drawCheck( + checkColor = checkColor.value, + checkFraction = checkDrawFraction, + crossCenterGravitation = checkCenterGravitationShiftFraction, + strokeWidthPx = strokeWidthPx, + drawingCache = checkCache, + ) + } +} + +// Copied from AOSP +// TODO: documentation +@Immutable +private class CheckDrawingCache( + val checkPath: Path = Path(), + val pathMeasure: PathMeasure = PathMeasure(), + val pathToDraw: Path = Path(), +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + + if (javaClass != other?.javaClass) return false + + other as CheckDrawingCache + + if (checkPath != other.checkPath) return false + if (pathMeasure != other.pathMeasure) return false + if (pathToDraw != other.pathToDraw) return false + + return true + } + + override fun hashCode(): Int { + var result = checkPath.hashCode() + result = 31 * result + pathMeasure.hashCode() + result = 31 * result + pathToDraw.hashCode() + return result + } +} + +/** + * [start], [stop] 사이의 임의의 지점 f(p) 를 반환합니다. + * [fraction] 은 임의의 점 p 까지의 거리를 의미합니다. + * + * @param start 시작 지점 + * @param stop 도착 지점 + * @param fraction 임의의 점 p 까지의 거리 + * + * @return [start], [stop] 사이의 임의의 지점 f(p) + */ +@Suppress("SameParameterValue") +private fun linearInterpolation( + start: Float, + stop: Float, + fraction: Float, +) = (1 - fraction) * start + fraction * stop + +/** + * [DrawScope] 에 체크 표시를 그립니다. + * [crossCenterGravitation] 의 값이 0f -> 1f -> 0f 이므로, + * 중심지점일수록 그려지는 속도가 빨라집니다. + * + * @param checkColor 체크 표시의 색상 + * @param checkFraction 체크 표시가 끝나는 지점까지의 거리 + * @param crossCenterGravitation 중심지점의 중력 + * @param strokeWidthPx 선의 굵기 + * @param drawingCache 그려질 선의 경로를 저장하는 캐시 + */ +@Stable +private fun DrawScope.drawCheck( + checkColor: Color, + checkFraction: Float, + crossCenterGravitation: Float, + strokeWidthPx: Float, + drawingCache: CheckDrawingCache, +): Unit = with( + receiver = QuackToggleDefaults.DrawConstaints, +) { + val stroke = Stroke( + width = strokeWidthPx, + cap = StrokeCap.Round, + ) + val width = size.width + + val gravitatedCrossX = linearInterpolation( + start = CheckCrossX, + stop = StopLocation, + fraction = crossCenterGravitation, + ) + val gravitatedCrossY = linearInterpolation( + start = CheckCrossY, + stop = StopLocation, + fraction = crossCenterGravitation, + ) + + val gravitatedLeftY = linearInterpolation( + start = LeftY, + stop = StopLocation, + fraction = crossCenterGravitation, + ) + val gravitatedRightY = linearInterpolation( + start = RightY, + stop = StopLocation, + fraction = crossCenterGravitation, + ) + + with( + receiver = drawingCache, + ) { + checkPath.reset() + checkPath.moveTo( + x = width * LeftX, + y = width * gravitatedLeftY, + ) + checkPath.lineTo( + x = width * gravitatedCrossX, + y = width * gravitatedCrossY, + ) + checkPath.lineTo( + x = width * RightX, + y = width * gravitatedRightY, + ) + pathMeasure.setPath( + path = checkPath, + forceClosed = false, + ) + pathToDraw.reset() + pathMeasure.getSegment( + startDistance = 0f, + stopDistance = pathMeasure.length * checkFraction, + destination = pathToDraw, + startWithMoveTo = true, + ) + } + drawPath( + path = drawingCache.pathToDraw, + color = checkColor, + style = stroke, + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/screen/SearchTagScreen.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/screen/SearchTagScreen.kt index 335b95c40..3abd32170 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/screen/SearchTagScreen.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/screen/SearchTagScreen.kt @@ -8,6 +8,7 @@ package team.duckie.app.android.common.compose.ui.screen import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -32,14 +33,14 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch import team.duckie.app.android.common.compose.R import team.duckie.app.android.common.compose.ui.ImeSpacer -import team.duckie.app.android.common.compose.ui.icon.v1.CloseId import team.duckie.app.android.common.compose.ui.icon.v1.SearchId import team.duckie.app.android.common.compose.ui.quack.QuackNoUnderlineTextField import team.duckie.app.android.common.compose.ui.quack.todo.QuackLazyVerticalGridTag import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar -import team.duckie.quackquack.animation.QuackAnimatedVisibility import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackText @@ -86,7 +87,7 @@ fun SearchTagScreen( ) { QuackTopAppBar( leadingText = title, - trailingIconResId = QuackIcon.CloseId, + trailingIcon = QuackIcon.Outlined.Close, onTrailingIconClick = onCloseClick, ) @@ -100,7 +101,6 @@ fun SearchTagScreen( ), horizontalSpace = 4.dp, items = tags, - tagTypeResId = QuackIcon.CloseId, onClick = { onTagClick(it) }, itemChunkedSize = 3, ) @@ -129,7 +129,7 @@ fun SearchTagScreen( ), ) - QuackAnimatedVisibility( + AnimatedVisibility( modifier = Modifier.padding( top = 8.dp, ), diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexiblePrimaryLargeButton.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexiblePrimaryLargeButton.kt new file mode 100644 index 000000000..03dce6e9e --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexiblePrimaryLargeButton.kt @@ -0,0 +1,66 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.temp + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.modifier.quackClickable + +/** + * QuackPrimaryLargeButton의 크키가 조정되지 않는 오류를 대처하기 위한 임시 버튼 + * + * TODO(limsaehyun): 추후 QuackQuackV2 로 대체되어야 함 + */ +@Composable +fun TempFlexiblePrimaryLargeButton( + modifier: Modifier = Modifier, + text: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .heightIn(44.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundFor(enabled)) + .quackClickable(onClick = onClickFor(enabled, onClick)), + contentAlignment = Alignment.Center, + ) { + QuackText( + text = text, + typography = QuackTypography.Subtitle.change( + color = QuackColor.White, + ), + ) + } +} + +private fun backgroundFor(enabled: Boolean) = if (enabled) { + QuackColor.DuckieOrange.value +} else { + QuackColor.Gray2.value +} + +private fun onClickFor( + enabled: Boolean, + onClick: () -> Unit, +) = if (enabled) { + onClick +} else { + null +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexibleSecondaryLargButton.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexibleSecondaryLargButton.kt new file mode 100644 index 000000000..860c79e5e --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexibleSecondaryLargButton.kt @@ -0,0 +1,56 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.temp + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.modifier.quackClickable + +/** + * QuackPrimaryLargeButton의 크키가 조정되지 않는 오류를 대처하기 위한 임시 버튼 + * + * TODO(limsaehyun): 추후 QuackQuackV2 로 대체되어야 함 + */ +@Composable +fun TempFlexibleSecondaryLargeButton( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .heightIn(44.dp) + .quackBorder( + border = QuackBorder(color = QuackColor.Gray3), + shape = RoundedCornerShape(8.dp), + ) + .clip(RoundedCornerShape(8.dp)) + .background(QuackColor.White.value) + .quackClickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + QuackText( + text = text, + typography = QuackTypography.Subtitle.change( + color = QuackColor.Black, + ), + ) + } +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/ExpendedClickable.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/ExpendedClickable.kt index 5a3d9715e..902f70dfa 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/ExpendedClickable.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/ExpendedClickable.kt @@ -7,60 +7,13 @@ package team.duckie.app.android.common.compose.util -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.layout -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.quackClickable -@Preview -@Composable -fun PreviewExpandedClickable() { - Column( - modifier = Modifier - .fillMaxSize() - .background( - color = Color.White, - ) - .padding( - horizontal = 16.dp, - ), - ) { - Box( - modifier = Modifier - .background(Color.Black) - .size(50.dp), - ) - Box( - modifier = Modifier - .background(QuackColor.Gray4.value) - .size(50.dp) - .expendedQuackClickable { - }, - ) - Box( - modifier = Modifier - .background(QuackColor.Gray4.value) - .size(50.dp) - .expendedQuackClickable( - verticalExpendedSize = 24.dp, - horizontalExpendedSize = 24.dp, - ) { - }, - ) - } -} - fun Modifier.expendedQuackClickable( verticalExpendedSize: Dp = 12.dp, horizontalExpendedSize: Dp = 12.dp, diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/HandleKeyBoard.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/HandleKeyBoard.kt index 52f4ed6df..b2b5dac7d 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/HandleKeyBoard.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/HandleKeyBoard.kt @@ -9,12 +9,17 @@ package team.duckie.app.android.common.compose.util +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import team.duckie.app.android.common.compose.rememberKeyboardVisible @@ -29,3 +34,15 @@ fun HandleKeyboardVisibilityWithSheet(sheetState: ModalBottomSheetState) { } } } + +fun Modifier.addFocusCleaner( + doOnClear: () -> Unit = {}, +): Modifier = composed { + val focusManager = LocalFocusManager.current + pointerInput(Unit) { + detectTapGestures(onTap = { + doOnClear() + focusManager.clearFocus() + },) + } +} diff --git a/common/compose/src/main/res/drawable/ic_clock_12.xml b/common/compose/src/main/res/drawable/ic_clock_12.xml deleted file mode 100644 index 60d169843..000000000 --- a/common/compose/src/main/res/drawable/ic_clock_12.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/common/compose/src/main/res/values/strings.xml b/common/compose/src/main/res/values/strings.xml index 637a17642..7c07eef4b 100644 --- a/common/compose/src/main/res/values/strings.xml +++ b/common/compose/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ 응시자 추천 좋아요 + 차단해제 해당 기능은 개발 예정입니다.\n기대해주세요 최소 2 이상의 columns가 필요합니다. @@ -42,5 +43,5 @@ 네트워크 연결이 불안정해요\n잠시 후 다시 시도해 주세요. 추가한 태그 완료 - 태그 입력하기 + 태그 입력하기 (최대 10자) diff --git a/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/exception/constant.kt b/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/exception/constant.kt index c73e39f30..cc9584e2d 100644 --- a/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/exception/constant.kt +++ b/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/exception/constant.kt @@ -44,6 +44,8 @@ object ExceptionCode { "KAKAOTALK_NOT_SUPPORT_EXCEPTION" const val HEART_NOT_FOUND = "HEART_NOT_FOUND" + + const val KAKAO_CANCELLED = "KAKAO_CANCELLED" } val Throwable.isHeartNotFound: Boolean @@ -83,3 +85,6 @@ val Throwable.isKakaoTalkNotConnectedAccount: Boolean val Throwable.isKakaoTalkNotSupportAccount: Boolean get() = (this as? DuckieThirdPartyException)?.code == ExceptionCode.KAKAOTALK_NOT_SUPPORT_EXCEPTION + +val Throwable.isKakaoCancelled: Boolean + get() = (this as? DuckieThirdPartyException)?.code == ExceptionCode.KAKAO_CANCELLED diff --git a/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/number.kt b/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/number.kt new file mode 100644 index 000000000..acb582cd9 --- /dev/null +++ b/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/number.kt @@ -0,0 +1,14 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.kotlin + +import java.text.DecimalFormat + +private val decimalFormat = DecimalFormat("#,###") + +fun Int.toDecimalFormat() = decimalFormat.format(this) diff --git a/core/datastore/src/main/kotlin/team/duckie/app/android/core/datastore/datastore.kt b/core/datastore/src/main/kotlin/team/duckie/app/android/core/datastore/datastore.kt index 1c323cbfd..b0bcf9ccb 100644 --- a/core/datastore/src/main/kotlin/team/duckie/app/android/core/datastore/datastore.kt +++ b/core/datastore/src/main/kotlin/team/duckie/app/android/core/datastore/datastore.kt @@ -40,6 +40,10 @@ object PreferenceKey { object DevMode { val IsStage = booleanPreferencesKey(buildPreferenceKey(type = "devMode", token = "isStage")) } + + object FeatureFlag { + val IsProceedEnable = booleanPreferencesKey(buildPreferenceKey(type = "feature", token = "isProceedEnable")) + } } val Context.dataStore by preferencesDataStore( diff --git a/data/src/main/kotlin/team/duckie/app/android/data/exam/mapper/mapper.kt b/data/src/main/kotlin/team/duckie/app/android/data/exam/mapper/mapper.kt index 791cffd95..3026b449f 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/exam/mapper/mapper.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/exam/mapper/mapper.kt @@ -10,16 +10,20 @@ package team.duckie.app.android.data.exam.mapper import kotlinx.collections.immutable.toImmutableList import team.duckie.app.android.common.kotlin.AllowCyclomaticComplexMethod import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe +import team.duckie.app.android.common.kotlin.exception.duckieSimpleResponseFieldNpe import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.data.category.mapper.toDomain import team.duckie.app.android.data.exam.model.AnswerData import team.duckie.app.android.data.exam.model.ChoiceData +import team.duckie.app.android.data.exam.model.ExamBlockDetailResponse +import team.duckie.app.android.data.exam.model.ExamBlockResponse import team.duckie.app.android.data.exam.model.ExamBodyData import team.duckie.app.android.data.exam.model.ExamData import team.duckie.app.android.data.exam.model.ExamInfoEntity import team.duckie.app.android.data.exam.model.ExamInstanceBodyData import team.duckie.app.android.data.exam.model.ExamInstanceSubmitBodyData import team.duckie.app.android.data.exam.model.ExamInstanceSubmitData +import team.duckie.app.android.data.exam.model.ExamMeBlocksResponse import team.duckie.app.android.data.exam.model.ExamMeFollowingResponseData import team.duckie.app.android.data.exam.model.ExamThumbnailBodyData import team.duckie.app.android.data.exam.model.ExamsData @@ -37,6 +41,8 @@ import team.duckie.app.android.data.user.mapper.toDomain import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.ChoiceModel import team.duckie.app.android.domain.exam.model.Exam +import team.duckie.app.android.domain.exam.model.ExamBlock +import team.duckie.app.android.domain.exam.model.IgnoreExam import team.duckie.app.android.domain.exam.model.ExamBody import team.duckie.app.android.domain.exam.model.ExamInfo import team.duckie.app.android.domain.exam.model.ExamInstanceBody @@ -282,3 +288,20 @@ internal fun SolutionData.toDomain() = Solution( title = title, description = description, ) + +internal fun ExamMeBlocksResponse.toDomain(): List = + exams?.fastMap { it.toDomain() } ?: duckieSimpleResponseFieldNpe("exams") + +internal fun ExamBlockDetailResponse.toDomain() = IgnoreExam( + id = id ?: duckieSimpleResponseFieldNpe("exams"), + title = title ?: duckieSimpleResponseFieldNpe("title"), + thumbnailUrl = thumbnailUrl + ?: duckieSimpleResponseFieldNpe("thumbnailUrl"), + user = user?.toDomain() ?: duckieSimpleResponseFieldNpe("user"), + examBlock = examBlock?.toDomain() + ?: duckieSimpleResponseFieldNpe("examBlock"), +) + +internal fun ExamBlockResponse.toDomain() = ExamBlock( + id = id ?: duckieSimpleResponseFieldNpe("id"), +) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/exam/model/ExamMeBlocksResponse.kt b/data/src/main/kotlin/team/duckie/app/android/data/exam/model/ExamMeBlocksResponse.kt new file mode 100644 index 000000000..4d7d9eb3e --- /dev/null +++ b/data/src/main/kotlin/team/duckie/app/android/data/exam/model/ExamMeBlocksResponse.kt @@ -0,0 +1,27 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.data.exam.model + +import com.fasterxml.jackson.annotation.JsonProperty +import team.duckie.app.android.data.user.model.UserResponse + +data class ExamMeBlocksResponse( + @field:JsonProperty("exams") val exams: List?, +) + +data class ExamBlockDetailResponse( + @field:JsonProperty("id") val id: Int?, + @field:JsonProperty("title") val title: String?, + @field:JsonProperty("thumbnailUrl") val thumbnailUrl: String?, + @field:JsonProperty("user") val user: UserResponse?, + @field:JsonProperty("examBlock") val examBlock: ExamBlockResponse?, +) + +data class ExamBlockResponse( + @field:JsonProperty("id") val id: Int?, +) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/exam/paging/ProfileExamPagingSource.kt b/data/src/main/kotlin/team/duckie/app/android/data/exam/paging/ProfileExamPagingSource.kt index cfee2e23f..9f8c95098 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/exam/paging/ProfileExamPagingSource.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/exam/paging/ProfileExamPagingSource.kt @@ -35,7 +35,7 @@ class ProfileExamPagingSource( } override fun getRefreshKey(state: PagingState): Int? { - return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2) - .coerceAtLeast(0) + return ((state.anchorPosition ?: STARTING_KEY) - state.config.initialLoadSize / 2) + .coerceAtLeast(STARTING_KEY) } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/exam/repository/ExamRepositoryImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/exam/repository/ExamRepositoryImpl.kt index f834dd1f4..92bc3f173 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/exam/repository/ExamRepositoryImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/exam/repository/ExamRepositoryImpl.kt @@ -11,6 +11,7 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.github.kittinunf.fuel.Fuel +import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post @@ -25,17 +26,20 @@ import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe import team.duckie.app.android.data._datasource.client import team.duckie.app.android.data._exception.util.responseCatching import team.duckie.app.android.data._exception.util.responseCatchingFuel +import team.duckie.app.android.data._util.jsonBody import team.duckie.app.android.data._util.toStringJsonMap import team.duckie.app.android.data.exam.datasource.ExamInfoDataSource import team.duckie.app.android.data.exam.mapper.toData import team.duckie.app.android.data.exam.mapper.toDomain import team.duckie.app.android.data.exam.model.ExamData import team.duckie.app.android.data.exam.model.ExamInfoEntity +import team.duckie.app.android.data.exam.model.ExamMeBlocksResponse import team.duckie.app.android.data.exam.model.ExamMeFollowingResponseData import team.duckie.app.android.data.exam.model.ProfileExamDatas import team.duckie.app.android.data.exam.paging.ExamMeFollowingPagingSource import team.duckie.app.android.data.exam.paging.ProfileExamPagingSource import team.duckie.app.android.domain.exam.model.Exam +import team.duckie.app.android.domain.exam.model.IgnoreExam import team.duckie.app.android.domain.exam.model.ExamBody import team.duckie.app.android.domain.exam.model.ExamInfo import team.duckie.app.android.domain.exam.model.ExamThumbnailBody @@ -176,6 +180,27 @@ class ExamRepositoryImpl @Inject constructor( ).flow } + override suspend fun getIgnoreExams(): List { + val response = client.get("exams/me/blocks") + return responseCatching( + response = response, + parse = ExamMeBlocksResponse::toDomain, + ) + } + + override suspend fun cancelExamIgnore(examId: Int): Boolean { + val response = client.delete("exam-block") { + jsonBody { + "targetId" withInt examId + } + } + + return responseCatching(response.status.value, response.bodyAsText()) { body -> + val json = body.toStringJsonMap() + json["success"]?.toBoolean() ?: duckieResponseFieldNpe("success") + } + } + override suspend fun getMadeExams(): List { return examInfoDataSource.getMadeExams().map(ExamInfoEntity::toDomain) } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/examInstance/paging/ProfileExamInstancePagingSource.kt b/data/src/main/kotlin/team/duckie/app/android/data/examInstance/paging/ProfileExamInstancePagingSource.kt index 2e4c57b8e..abb35d36b 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/examInstance/paging/ProfileExamInstancePagingSource.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/examInstance/paging/ProfileExamInstancePagingSource.kt @@ -36,7 +36,7 @@ class ProfileExamInstancePagingSource( } override fun getRefreshKey(state: PagingState): Int? { - return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2) - .coerceAtLeast(0) + return ((state.anchorPosition ?: STARTING_KEY) - state.config.initialLoadSize / 2) + .coerceAtLeast(STARTING_KEY) } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/kakao/repository/KakaoRepositoryImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/kakao/repository/KakaoRepositoryImpl.kt index 672a26f0e..d061962d3 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/kakao/repository/KakaoRepositoryImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/kakao/repository/KakaoRepositoryImpl.kt @@ -9,13 +9,14 @@ package team.duckie.app.android.data.kakao.repository import android.content.Context import com.kakao.sdk.common.model.AuthError +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause import com.kakao.sdk.user.UserApiClient import dagger.hilt.android.qualifiers.ActivityContext import kotlinx.coroutines.suspendCancellableCoroutine import team.duckie.app.android.common.kotlin.exception.DuckieThirdPartyException import team.duckie.app.android.common.kotlin.exception.ExceptionCode import team.duckie.app.android.domain.kakao.repository.KakaoRepository -import java.lang.ref.WeakReference import javax.inject.Inject import kotlin.Result.Companion.failure import kotlin.Result.Companion.success @@ -23,6 +24,10 @@ import kotlin.coroutines.resume private val KakaoLoginException = IllegalStateException("Kakao API response is nothing.") +private val KakaoCancelledException = DuckieThirdPartyException( + code = ExceptionCode.KAKAO_CANCELLED, +) + private val KakaoTalkNotSupportException = DuckieThirdPartyException( code = ExceptionCode.KAKAOTALK_NOT_SUPPORT_EXCEPTION, ) @@ -34,11 +39,9 @@ private const val KakaoNotSupportStatusCode: Int = 302 class KakaoRepositoryImpl @Inject constructor( @ActivityContext private val activityContext: Context, ) : KakaoRepository { - private val _activity = WeakReference(activityContext) - private val activity get() = _activity.get()!! override suspend fun getAccessToken(): String { - return if (UserApiClient.instance.isKakaoTalkLoginAvailable(activity)) { + return if (UserApiClient.instance.isKakaoTalkLoginAvailable(activityContext)) { loginWithKakaoTalk() } else { loginWithWebView() @@ -47,7 +50,7 @@ class KakaoRepositoryImpl @Inject constructor( private suspend fun loginWithKakaoTalk(): String { return suspendCancellableCoroutine { continuation -> - UserApiClient.instance.loginWithKakaoTalk(activity) { token, error -> + UserApiClient.instance.loginWithKakaoTalk(activityContext) { token, error -> continuation.resume( when { error != null -> { @@ -60,6 +63,10 @@ class KakaoRepositoryImpl @Inject constructor( } } + is ClientError -> { + failure(filterKakaoClientError(error)) + } + else -> failure(error) } } @@ -74,10 +81,13 @@ class KakaoRepositoryImpl @Inject constructor( override suspend fun loginWithWebView(): String { return suspendCancellableCoroutine { continuation -> - UserApiClient.instance.loginWithKakaoAccount(activity) { token, error -> + UserApiClient.instance.loginWithKakaoAccount(activityContext) { token, error -> continuation.resume( when { - error != null -> failure(error) + error != null -> when (error) { + is ClientError -> failure(filterKakaoClientError(error)) + else -> failure(error) + } token != null -> success(token.accessToken) else -> failure(KakaoLoginException) }, @@ -85,4 +95,11 @@ class KakaoRepositoryImpl @Inject constructor( } }.getOrThrow() } + + private fun filterKakaoClientError(clientError: ClientError): RuntimeException { + return when (clientError.reason) { + ClientErrorCause.Cancelled -> KakaoCancelledException + else -> clientError + } + } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/me/repository/MeRepositoryImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/me/repository/MeRepositoryImpl.kt index e2c89183c..765369ab7 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/me/repository/MeRepositoryImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/me/repository/MeRepositoryImpl.kt @@ -31,6 +31,7 @@ class MeRepositoryImpl @Inject constructor( private val dataStore: DataStore, ) : MeRepository { private var isStageChecked: Boolean = false + private var isProceedEnabled: Boolean? = null private var me: User? = null override suspend fun getMe(): User { // 0. DevMode 에서 API @@ -39,6 +40,9 @@ class MeRepositoryImpl @Inject constructor( devModeDataSource.setApiEnvironment(getIsStage()) } + // 1. 피처 플래그 갱신 + featureFlagCheck() + // 1. DataStore 에 토큰 값이 있는지 체크 val meToken = getMeToken() ?: duckieClientLogicProblemException(code = ClientMeTokenNull) @@ -67,6 +71,15 @@ class MeRepositoryImpl @Inject constructor( } } + /** featureFlag 값을 체크하여, 각 플래그 항목들을 갱신한다. */ + private suspend fun featureFlagCheck() { + isProceedEnabled = if (isProceedEnabled == null) { + dataStore.data.first()[PreferenceKey.FeatureFlag.IsProceedEnable] ?: false + } else { + false + } + } + override suspend fun setMe(newMe: User) { me = newMe } @@ -94,4 +107,10 @@ class MeRepositoryImpl @Inject constructor( // ref: https://medium.com/androiddevelopers/datastore-and-synchronous-work-576f3869ec4c return dataStore.data.first()[PreferenceKey.DevMode.IsStage] ?: false } + + override suspend fun getIsProceedEnable(): Boolean { + // TODO(riflockle7): 더 좋은 구현 방법이 있을까? + // ref: https://medium.com/androiddevelopers/datastore-and-synchronous-work-576f3869ec4c + return dataStore.data.first()[PreferenceKey.FeatureFlag.IsProceedEnable] ?: false + } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/notification/mapper/mapper.kt b/data/src/main/kotlin/team/duckie/app/android/data/notification/mapper/mapper.kt index f0d0a6f32..309490182 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/notification/mapper/mapper.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/notification/mapper/mapper.kt @@ -7,7 +7,6 @@ package team.duckie.app.android.data.notification.mapper -import team.duckie.app.android.data._util.toDate import team.duckie.app.android.data.notification.model.NotificationResponse import team.duckie.app.android.data.notification.model.NotificationsResponse import team.duckie.app.android.domain.notification.model.Notification @@ -20,10 +19,10 @@ internal fun NotificationsResponse.toDomain() = internal fun NotificationResponse.toDomain() = Notification( id = id ?: duckieResponseFieldNpe("${this::class.java.simpleName}.id"), - title = title ?: duckieResponseFieldNpe("${this::class.java.simpleName}.title"), + title = title, body = body ?: duckieResponseFieldNpe("${this::class.java.simpleName}.body"), thumbnailUrl = thumbnailUrl ?: duckieResponseFieldNpe("${this::class.java.simpleName}.thumbnailUrl"), - createdAt = createdAt?.toDate() + createdAt = createdAt ?: duckieResponseFieldNpe("${this::class.java.simpleName}.createdAt"), ) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/search/paging/SearchExamPagingSource.kt b/data/src/main/kotlin/team/duckie/app/android/data/search/paging/SearchExamPagingSource.kt index 596567a5e..947a7b660 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/search/paging/SearchExamPagingSource.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/search/paging/SearchExamPagingSource.kt @@ -36,7 +36,7 @@ internal class SearchExamPagingSource( } override fun getRefreshKey(state: PagingState): Int { - return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2) - .coerceAtLeast(0) + return ((state.anchorPosition ?: SearchExamStartingKey) - state.config.initialLoadSize / 2) + .coerceAtLeast(SearchExamStartingKey) } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/terms/mapper/data2domain.kt b/data/src/main/kotlin/team/duckie/app/android/data/terms/mapper/data2domain.kt index 0314bb13c..b30994c0c 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/terms/mapper/data2domain.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/terms/mapper/data2domain.kt @@ -7,10 +7,10 @@ package team.duckie.app.android.data.terms.mapper +import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe import team.duckie.app.android.data._util.toDate import team.duckie.app.android.data.terms.model.TermsResponseData import team.duckie.app.android.domain.terms.model.Terms -import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe internal fun TermsResponseData.toDomain() = Terms( id = id ?: duckieResponseFieldNpe("id"), diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserDataSource.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserDataSource.kt index b88682ee5..1bc3fc96b 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserDataSource.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserDataSource.kt @@ -9,6 +9,7 @@ package team.duckie.app.android.data.user.datasource import team.duckie.app.android.domain.category.model.Category import team.duckie.app.android.domain.tag.model.Tag +import team.duckie.app.android.domain.user.model.IgnoreUser import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.domain.user.model.UserFollowings import team.duckie.app.android.domain.user.model.UserProfile @@ -39,4 +40,6 @@ interface UserDataSource { suspend fun fetchUserFollowings(userId: Int): List suspend fun fetchUserFollowers(userId: Int): List + + suspend fun fetchIgnoreUsers(): List } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserRemoteDataSourceImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserRemoteDataSourceImpl.kt index 183e3c200..82cc18c45 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserRemoteDataSourceImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserRemoteDataSourceImpl.kt @@ -35,6 +35,8 @@ import team.duckie.app.android.common.kotlin.ExperimentalApi import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.common.kotlin.runtimeCheck +import team.duckie.app.android.data.user.model.UserMeIgnoreResponse +import team.duckie.app.android.domain.user.model.IgnoreUser import javax.inject.Inject class UserRemoteDataSourceImpl @Inject constructor( @@ -155,4 +157,12 @@ class UserRemoteDataSourceImpl @Inject constructor( parse = UsersResponse::toDomain, ) } + + override suspend fun fetchIgnoreUsers(): List { + val response = client.get("users/me/blocks") + return responseCatching( + response = response.body(), + parse = UserMeIgnoreResponse::toDomain, + ) + } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/mapper/data2domain.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/mapper/data2domain.kt index 8875a4899..8b173841a 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/user/mapper/data2domain.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/mapper/data2domain.kt @@ -32,7 +32,14 @@ import team.duckie.app.android.domain.user.model.UserFollowings import team.duckie.app.android.domain.user.model.UserProfile import team.duckie.app.android.domain.user.model.toUserAuthStatus import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe +import team.duckie.app.android.common.kotlin.exception.duckieSimpleResponseFieldNpe +import team.duckie.app.android.common.kotlin.exception.getFieldName import team.duckie.app.android.common.kotlin.fastMap +import team.duckie.app.android.data.user.model.IgnoreUserResponse +import team.duckie.app.android.data.user.model.UserBlockResponse +import team.duckie.app.android.data.user.model.UserMeIgnoreResponse +import team.duckie.app.android.domain.user.model.IgnoreUser +import team.duckie.app.android.domain.user.model.UserBlock import kotlin.random.Random private const val NicknameSuffixMaxLength = 10_000 @@ -96,3 +103,18 @@ internal fun UserProfileData.toDomain() = UserProfile( heartExams = heartExams?.map(ProfileExamData::toDomain), user = user?.toDomain(), ) + +internal fun UserBlockResponse.toDomain() = UserBlock( + id = id ?: duckieResponseFieldNpe(getFieldName("id")), +) + +internal fun IgnoreUserResponse.toDomain() = IgnoreUser( + id = id ?: duckieSimpleResponseFieldNpe("id"), + nickName = nickName ?: duckieSimpleResponseFieldNpe("nickName"), + profileImageUrl = profileImageUrl ?: duckieSimpleResponseFieldNpe("profileImageUrl"), + duckPower = duckPower?.toDomain(), + userBlock = userBlock?.toDomain() ?: duckieSimpleResponseFieldNpe("userBlock"), +) + +internal fun UserMeIgnoreResponse.toDomain(): List = + users?.fastMap { it.toDomain() } ?: duckieSimpleResponseFieldNpe("users") diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserBlockResponse.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserBlockResponse.kt new file mode 100644 index 000000000..b763282c9 --- /dev/null +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserBlockResponse.kt @@ -0,0 +1,15 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.data.user.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class UserBlockResponse( + @field:JsonProperty("id") + val id: Int? = null, +) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserMeIgnoreResponse.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserMeIgnoreResponse.kt new file mode 100644 index 000000000..4047c7c42 --- /dev/null +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserMeIgnoreResponse.kt @@ -0,0 +1,24 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.data.user.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class UserMeIgnoreResponse( + @field:JsonProperty("users") + val users: List? = null, +) + +data class IgnoreUserResponse( + @field:JsonProperty("id") val id: Int? = null, + @field:JsonProperty("nickName") val nickName: String? = null, + @field:JsonProperty("profileImageUrl") val profileImageUrl: String? = null, + @field:JsonProperty("duckPower") val duckPower: DuckPowerResponse? = null, + @field:JsonProperty("userBlock") val userBlock: UserBlockResponse? = null, + @field:JsonProperty("permissions") val permissions: List? = null, +) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/repository/UserRepositoryImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/repository/UserRepositoryImpl.kt index 3c11d1ee2..b506b0e66 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/user/repository/UserRepositoryImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/repository/UserRepositoryImpl.kt @@ -12,6 +12,7 @@ import team.duckie.app.android.common.kotlin.ExperimentalApi import team.duckie.app.android.data.user.datasource.UserDataSource import team.duckie.app.android.domain.category.model.Category import team.duckie.app.android.domain.tag.model.Tag +import team.duckie.app.android.domain.user.model.IgnoreUser import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.domain.user.model.UserFollowings import team.duckie.app.android.domain.user.model.UserProfile @@ -66,4 +67,8 @@ class UserRepositoryImpl @Inject constructor( override suspend fun fetchUserFollowers(userId: Int): List { return userDataSource.fetchUserFollowers(userId) } + + override suspend fun fetchIgnoreUsers(): List { + return userDataSource.fetchIgnoreUsers() + } } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/DeleteExamBlockResponse.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/DeleteExamBlockResponse.kt new file mode 100644 index 000000000..88613b4cb --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/DeleteExamBlockResponse.kt @@ -0,0 +1,15 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class DeleteExamBlockResponse( + @field:JsonProperty("success") + val success: Boolean, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/ExamBlock.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/ExamBlock.kt new file mode 100644 index 000000000..099bd7529 --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/ExamBlock.kt @@ -0,0 +1,12 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.model + +data class ExamBlock( + val id: Int, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/IgnoreExam.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/IgnoreExam.kt new file mode 100644 index 000000000..cf6f385db --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/IgnoreExam.kt @@ -0,0 +1,18 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.model + +import team.duckie.app.android.domain.user.model.User + +data class IgnoreExam( + val id: Int, + val title: String, + val thumbnailUrl: String, + val user: User, + val examBlock: ExamBlock, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/repository/ExamRepository.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/repository/ExamRepository.kt index 098b50f9e..5d9733d6b 100644 --- a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/repository/ExamRepository.kt +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/repository/ExamRepository.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable import androidx.paging.PagingData import kotlinx.coroutines.flow.Flow import team.duckie.app.android.domain.exam.model.Exam +import team.duckie.app.android.domain.exam.model.IgnoreExam import team.duckie.app.android.domain.exam.model.ExamBody import team.duckie.app.android.domain.exam.model.ExamInfo import team.duckie.app.android.domain.exam.model.ExamThumbnailBody @@ -37,4 +38,8 @@ interface ExamRepository { suspend fun getHeartExam(userId: Int): Flow> suspend fun getSubmittedExam(userId: Int): Flow> + + suspend fun getIgnoreExams(): List + + suspend fun cancelExamIgnore(examId: Int): Boolean } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/CancelExamIgnoreUseCase.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/CancelExamIgnoreUseCase.kt new file mode 100644 index 000000000..be25c1cd7 --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/CancelExamIgnoreUseCase.kt @@ -0,0 +1,22 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.usecase + +import androidx.compose.runtime.Immutable +import team.duckie.app.android.domain.exam.repository.ExamRepository +import javax.inject.Inject + +@Immutable +class CancelExamIgnoreUseCase @Inject constructor( + private val examRepository: ExamRepository, +) { + + suspend operator fun invoke(examId: Int) = runCatching { + examRepository.cancelExamIgnore(examId = examId) + } +} diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/GetExamIgnoresUseCase.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/GetExamIgnoresUseCase.kt new file mode 100644 index 000000000..9a5a247cc --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/GetExamIgnoresUseCase.kt @@ -0,0 +1,22 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.usecase + +import androidx.compose.runtime.Immutable +import team.duckie.app.android.domain.exam.repository.ExamRepository +import javax.inject.Inject + +@Immutable +class GetExamIgnoresUseCase @Inject constructor( + private val examRepository: ExamRepository, +) { + + suspend operator fun invoke() = runCatching { + examRepository.getIgnoreExams() + } +} diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/me/MeRepository.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/me/MeRepository.kt index af0e45c5f..1c2853996 100644 --- a/domain/src/main/kotlin/team/duckie/app/android/domain/me/MeRepository.kt +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/me/MeRepository.kt @@ -18,4 +18,6 @@ interface MeRepository { suspend fun clearMeToken() suspend fun getIsStage(): Boolean + + suspend fun getIsProceedEnable(): Boolean } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/me/usecase/GetAllFeatureFlagsUseCase.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/me/usecase/GetAllFeatureFlagsUseCase.kt new file mode 100644 index 000000000..805c8d312 --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/me/usecase/GetAllFeatureFlagsUseCase.kt @@ -0,0 +1,22 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.me.usecase + +import androidx.compose.runtime.Immutable +import team.duckie.app.android.domain.me.MeRepository +import javax.inject.Inject + +// TODO(riflockle7): 그냥 모든 피처 플래그를 Map 형태로 받아내는 건 어떨까? +@Immutable +class GetIsProceedEnableUseCase @Inject constructor( + private val repository: MeRepository, +) { + suspend operator fun invoke(): Result { + return runCatching { repository.getIsProceedEnable() } + } +} diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/notification/model/Notification.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/notification/model/Notification.kt index 989aae21f..143321a7c 100644 --- a/domain/src/main/kotlin/team/duckie/app/android/domain/notification/model/Notification.kt +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/notification/model/Notification.kt @@ -8,15 +8,14 @@ package team.duckie.app.android.domain.notification.model import androidx.compose.runtime.Immutable -import java.util.Date @Immutable data class Notification( val id: Int, - val title: String, + val title: String?, val body: String, val thumbnailUrl: String, - val createdAt: Date, + val createdAt: String, ) { companion object { fun empty(id: Int = 0) = Notification( @@ -24,7 +23,7 @@ data class Notification( title = "", body = "", thumbnailUrl = "", - createdAt = Date(0), + createdAt = "", ) } } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/IgnoreUser.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/IgnoreUser.kt new file mode 100644 index 000000000..c283a9096 --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/IgnoreUser.kt @@ -0,0 +1,16 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.user.model + +data class IgnoreUser( + val id: Int, + val nickName: String, + val profileImageUrl: String, + val duckPower: DuckPower?, + val userBlock: UserBlock, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/UserBlock.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/UserBlock.kt new file mode 100644 index 000000000..6fe083bfd --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/UserBlock.kt @@ -0,0 +1,12 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.user.model + +data class UserBlock( + val id: Int, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/user/repository/UserRepository.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/user/repository/UserRepository.kt index 0e504533b..26c187584 100644 --- a/domain/src/main/kotlin/team/duckie/app/android/domain/user/repository/UserRepository.kt +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/user/repository/UserRepository.kt @@ -10,6 +10,7 @@ package team.duckie.app.android.domain.user.repository import androidx.compose.runtime.Immutable import team.duckie.app.android.domain.category.model.Category import team.duckie.app.android.domain.tag.model.Tag +import team.duckie.app.android.domain.user.model.IgnoreUser import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.domain.user.model.UserFollowings import team.duckie.app.android.domain.user.model.UserProfile @@ -41,4 +42,6 @@ interface UserRepository { suspend fun fetchUserFollowings(userId: Int): List suspend fun fetchUserFollowers(userId: Int): List + + suspend fun fetchIgnoreUsers(): List } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/user/usecase/FetchIgnoreUsersUseCase.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/user/usecase/FetchIgnoreUsersUseCase.kt new file mode 100644 index 000000000..17dd0891d --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/user/usecase/FetchIgnoreUsersUseCase.kt @@ -0,0 +1,23 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.user.usecase + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.toImmutableList +import team.duckie.app.android.domain.user.repository.UserRepository +import javax.inject.Inject + +@Immutable +class FetchIgnoreUsersUseCase @Inject constructor( + private val userRepository: UserRepository, +) { + + suspend operator fun invoke() = runCatching { + userRepository.fetchIgnoreUsers().toImmutableList() + } +} diff --git a/feature/create-problem/build.gradle.kts b/feature/create-exam/build.gradle.kts similarity index 87% rename from feature/create-problem/build.gradle.kts rename to feature/create-exam/build.gradle.kts index 7b5a69b6c..3af91becb 100644 --- a/feature/create-problem/build.gradle.kts +++ b/feature/create-exam/build.gradle.kts @@ -14,7 +14,7 @@ plugins { } android { - namespace = "team.duckie.app.android.feature.create.problem" + namespace = "team.duckie.app.android.feature.create.exam" } dependencies { @@ -32,7 +32,8 @@ dependencies { libs.ktx.lifecycle.runtime, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold - libs.quack.ui.components, + libs.quack.v2.ui, + libs.kotlin.collections.immutable, libs.firebase.crashlytics, ) } diff --git a/feature/create-problem/src/main/AndroidManifest.xml b/feature/create-exam/src/main/AndroidManifest.xml similarity index 89% rename from feature/create-problem/src/main/AndroidManifest.xml rename to feature/create-exam/src/main/AndroidManifest.xml index d606d1dd0..632826cd7 100644 --- a/feature/create-problem/src/main/AndroidManifest.xml +++ b/feature/create-exam/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/CreateProblemActivity.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/CreateExamActivity.kt similarity index 77% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/CreateProblemActivity.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/CreateExamActivity.kt index ed9e78e1a..822ada064 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/CreateProblemActivity.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/CreateExamActivity.kt @@ -5,17 +5,19 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem +package team.duckie.app.android.feature.create.exam import android.os.Bundle import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.CircularProgressIndicator import androidx.compose.runtime.LaunchedEffect @@ -30,27 +32,27 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.create.problem.screen.AdditionalInformationScreen -import team.duckie.app.android.feature.create.problem.screen.CreateProblemScreen -import team.duckie.app.android.feature.create.problem.screen.ExamInformationScreen -import team.duckie.app.android.feature.create.problem.screen.SearchTagScreen -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.sideeffect.CreateProblemSideEffect -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemStep -import team.duckie.app.android.common.compose.ui.ErrorScreen -import team.duckie.app.android.common.compose.ui.LoadingScreen import team.duckie.app.android.common.android.exception.handling.reporter.reportToToast -import team.duckie.app.android.common.kotlin.exception.DuckieResponseException import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.common.android.ui.finishWithAnimation -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackTitle1 -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.app.android.common.compose.ui.ErrorScreen +import team.duckie.app.android.common.compose.ui.LoadingScreen +import team.duckie.app.android.common.kotlin.exception.DuckieResponseException +import team.duckie.app.android.feature.create.exam.screen.AdditionalInformationScreen +import team.duckie.app.android.feature.create.exam.screen.CreateExamScreen +import team.duckie.app.android.feature.create.exam.screen.ExamInformationScreen +import team.duckie.app.android.feature.create.exam.screen.SearchTagScreen +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.sideeffect.CreateProblemSideEffect +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.material.theme.QuackTheme +import team.duckie.quackquack.ui.QuackText @AndroidEntryPoint -class CreateProblemActivity : BaseActivity() { +class CreateExamActivity : BaseActivity() { private val viewModel: CreateProblemViewModel by viewModels() @@ -58,13 +60,13 @@ class CreateProblemActivity : BaseActivity() { super.onCreate(savedInstanceState) setContent { val rootState = viewModel.collectAsState().value - val createProblemStep = rootState.createProblemStep + val createExamStep = rootState.createExamStep val isMakeExamUploading = remember(rootState.isMakeExamUploading) { rootState.isMakeExamUploading } BackHandler { - when (createProblemStep) { + when (createExamStep) { CreateProblemStep.Loading, CreateProblemStep.Error -> finishWithAnimation() else -> {} } @@ -77,11 +79,12 @@ class CreateProblemActivity : BaseActivity() { } QuackTheme { - QuackAnimatedContent( + AnimatedContent( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor), - targetState = createProblemStep, + .background(color = QuackColor.White.value), + targetState = createExamStep, + label = "AnimatedContent", ) { step: CreateProblemStep -> when (step) { CreateProblemStep.Loading -> LoadingScreen( @@ -103,7 +106,7 @@ class CreateProblemActivity : BaseActivity() { .statusBarsPadding(), ) - CreateProblemStep.CreateProblem -> CreateProblemScreen( + CreateProblemStep.CreateExam -> CreateExamScreen( modifier = Modifier .fillMaxSize() .statusBarsPadding(), @@ -131,20 +134,20 @@ class CreateProblemActivity : BaseActivity() { modifier = Modifier .quackClickable(rippleEnabled = false) {} .fillMaxSize() - .background(color = QuackColor.Dimmed.composeColor), + .background(color = QuackColor.Dimmed.value), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { // 로딩 바 CircularProgressIndicator( - color = QuackColor.DuckieOrange.composeColor, + color = QuackColor.DuckieOrange.value, ) // 제목 - QuackTitle1( + QuackText( + modifier = Modifier.padding(PaddingValues(top = 8.dp)), text = stringResource(id = R.string.make_exam_loading), - color = QuackColor.White, - padding = PaddingValues(top = 8.dp), + typography = QuackTypography.Title1.change(QuackColor.White), ) } } diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/CreateProblemBottomLayout.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/CreateExamBottomLayout.kt similarity index 76% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/CreateProblemBottomLayout.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/CreateExamBottomLayout.kt index 5e7ecabd4..00b751ce0 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/CreateProblemBottomLayout.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/CreateExamBottomLayout.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -13,27 +13,28 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.layoutId import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import team.duckie.app.android.common.compose.asLoose +import team.duckie.app.android.common.compose.ui.QuackDivider import team.duckie.app.android.common.kotlin.fastFirstOrNull import team.duckie.app.android.common.kotlin.npe -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.border.applyAnimatedQuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackDivider -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackSubtitle2 -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackText internal fun getCreateProblemMeasurePolicy( topLayoutId: String, @@ -55,7 +56,7 @@ internal fun getCreateProblemMeasurePolicy( // TODO(riflockle7): 왜 이걸 더해야하는지 모르겠음.. padding 을 더하면 이렇게 됨... val bottomLayoutHeight = topAppBarMeasurable.height + 72.toDp().toPx().toInt() - // 3. createProblemButton 높이값 측정 + // 3. createExamButton 높이값 측정 val contentThresholdHeight = constraints.maxHeight - topAppBarHeight - bottomLayoutHeight val contentConstraints = constraints.copy( minHeight = contentThresholdHeight, @@ -88,7 +89,7 @@ internal fun getCreateProblemMeasurePolicy( @Composable internal fun CreateProblemBottomLayout( modifier: Modifier, - leftButtonLeadingIcon: QuackIcon? = null, + leftButtonLeadingIcon: ImageVector? = null, leftButtonText: String? = null, leftButtonClick: (() -> Unit)? = null, tempSaveButtonText: String? = null, @@ -99,7 +100,7 @@ internal fun CreateProblemBottomLayout( isValidateCheck: () -> Boolean, ) { val isValidate = isValidateCheck() - Column(modifier = modifier.background(QuackColor.White.composeColor)) { + Column(modifier = modifier.background(QuackColor.White.value)) { QuackDivider() Row( modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), @@ -119,10 +120,21 @@ internal fun CreateProblemBottomLayout( .padding(4.dp), ) { // TODO(riflockle7): 추후 비활성화 될 때의 resouce 이미지 필요 - leftButtonLeadingIcon?.let { QuackImage(src = it, size = DpSize(16.dp, 16.dp)) } - QuackSubtitle2( + leftButtonLeadingIcon?.let { + QuackIcon( + modifier = modifier.size(DpSize(16.dp, 16.dp)), + icon = it, + ) + } + QuackText( text = leftButtonText, - color = if (isCreateProblemValidate) QuackColor.Black else QuackColor.Gray2, + typography = QuackTypography.Subtitle2.change( + if (isCreateProblemValidate) { + QuackColor.Black + } else { + QuackColor.Gray2 + }, + ), ) } } @@ -132,30 +144,30 @@ internal fun CreateProblemBottomLayout( // 임시저장 버튼 tempSaveButtonClick?.let { requireNotNull(tempSaveButtonText) - QuackSubtitle( + QuackText( modifier = Modifier .clip(RoundedCornerShape(size = 8.dp)) - .background(QuackColor.White.composeColor) + .background(QuackColor.White.value) .quackClickable(onClick = tempSaveButtonClick) - .applyAnimatedQuackBorder( + .quackBorder( QuackBorder(1.dp, QuackColor.Gray3), shape = RoundedCornerShape(size = 8.dp), ) .padding(vertical = 12.dp, horizontal = 19.dp), - color = QuackColor.Black, + typography = QuackTypography.Subtitle.change(QuackColor.Black), text = tempSaveButtonText, ) } // 다음 버튼 - QuackSubtitle( + QuackText( modifier = Modifier .clip(RoundedCornerShape(size = 8.dp)) .background( if (isValidate) { - QuackColor.DuckieOrange.composeColor + QuackColor.DuckieOrange.value } else { - QuackColor.Gray2.composeColor + QuackColor.Gray2.value }, ) .quackClickable { @@ -163,7 +175,7 @@ internal fun CreateProblemBottomLayout( nextButtonClick() } } - .applyAnimatedQuackBorder( + .quackBorder( QuackBorder( 1.dp, if (isValidate) { @@ -178,7 +190,7 @@ internal fun CreateProblemBottomLayout( vertical = 12.dp, horizontal = 19.dp, ), - color = QuackColor.White, + typography = QuackTypography.Subtitle.change(QuackColor.White), text = nextButtonText, ) } diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FadeAnimatedVisibility.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FadeAnimatedVisibility.kt similarity index 91% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FadeAnimatedVisibility.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FadeAnimatedVisibility.kt index 1c0cf2ddd..7bf284fec 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FadeAnimatedVisibility.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FadeAnimatedVisibility.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FindTagItem.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FindTagItem.kt similarity index 59% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FindTagItem.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FindTagItem.kt index 0dd633f7e..0edf05886 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FindTagItem.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FindTagItem.kt @@ -5,22 +5,25 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.component.QuackBody1 +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.sugar.QuackBody1 @Composable internal fun SearchResultText( text: String, onClick: () -> Unit, ) = QuackBody1( - modifier = Modifier.fillMaxWidth(), - padding = PaddingValues(vertical = 12.dp), + modifier = Modifier + .quackClickable(onClick = onClick) + .fillMaxWidth() + .padding(PaddingValues(vertical = 12.dp)), text = text, - onClick = onClick, ) diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/NoLazyGridItems.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/NoLazyGridItems.kt similarity index 98% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/NoLazyGridItems.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/NoLazyGridItems.kt index 21ba7a498..e3108b3eb 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/NoLazyGridItems.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/NoLazyGridItems.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TextfieldOptions.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TextfieldOptions.kt similarity index 92% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TextfieldOptions.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TextfieldOptions.kt index 4197ca464..e51ec7afa 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TextfieldOptions.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TextfieldOptions.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TitleAndComponent.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TitleAndComponent.kt similarity index 92% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TitleAndComponent.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TitleAndComponent.kt index ceb6798f2..ed552774f 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TitleAndComponent.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TitleAndComponent.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement @@ -15,7 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.component.QuackHeadLine2 +import team.duckie.quackquack.ui.sugar.QuackHeadLine2 @Suppress("FunctionName") internal fun LazyListScope.TitleAndComponent( diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TopAppBar.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TopAppBar.kt similarity index 71% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TopAppBar.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TopAppBar.kt index 8f293b57b..14423fede 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TopAppBar.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TopAppBar.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -16,11 +16,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import team.duckie.app.android.feature.create.problem.R -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar +import team.duckie.app.android.feature.create.exam.R +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.sugar.QuackHeadLine2 @Composable internal fun PrevAndNextTopAppBar( @@ -31,8 +34,8 @@ internal fun PrevAndNextTopAppBar( trailingTextEnabled: Boolean = false, ) { // TODO(EvergreenTree97): enabled 속성 필요 QuackTopAppBar( - modifier = modifier, - leadingIcon = QuackIcon.ArrowBack, + modifier = modifier.padding(12.dp), + leadingIcon = OutlinedGroup.ArrowBack, leadingText = stringResource(id = R.string.create_problem), onLeadingIconClick = onLeadingIconClick, trailingText = trailingText, @@ -64,9 +67,9 @@ internal fun ExitAppBar( horizontalArrangement = Arrangement.SpaceBetween, ) { QuackHeadLine2(text = leadingText) - QuackImage( - src = QuackIcon.Close, - onClick = onTrailingIconClick, + QuackIcon( + modifier = Modifier.quackClickable(onClick = onTrailingIconClick), + icon = OutlinedGroup.Close, ) } } diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/Constant.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/Constant.kt new file mode 100644 index 000000000..ca0e2fa36 --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/Constant.kt @@ -0,0 +1,10 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.feature.create.exam.common.type + +internal const val MaximumChoice = 5 diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ImageChoiceLayout.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ImageChoiceLayout.kt new file mode 100644 index 000000000..7305dc4df --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ImageChoiceLayout.kt @@ -0,0 +1,196 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.feature.create.exam.common.type + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackRoundCheckBox +import team.duckie.app.android.domain.exam.model.Answer +import team.duckie.app.android.domain.exam.model.Question +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.NoLazyGridItems +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.icon.quackicon.outlined.Image +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +/** + * 객관식/사진 Layout + * // TODO(riflockle7): 정답 체크 연동 필요 + */ +@Composable +internal fun ImageChoiceLayout( + questionIndex: Int, + question: Question?, + titleChanged: (String) -> Unit, + imageClick: () -> Unit, + onDropdownItemClick: (Int) -> Unit, + answers: Answer.ImageChoice, + answerTextChanged: (String, Int) -> Unit, + answerImageClick: (Int) -> Unit, + addAnswerClick: () -> Unit, + correctAnswers: String?, + setCorrectAnswerClick: (String) -> Unit, + deleteLongClick: (Int?) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .quackClickable( + onLongClick = { deleteLongClick(null) }, + ) {}, + ) { + TitleView( + questionIndex, + question, + titleChanged, + imageClick, + answers.type.title, + onDropdownItemClick, + ) + + NoLazyGridItems( + count = answers.imageChoice.size, + nColumns = 2, + paddingValues = PaddingValues(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + itemContent = { answerIndex -> + val answerNo = answerIndex + 1 + val answerItem = answers.imageChoice[answerIndex] + val isChecked = correctAnswers == "$answerIndex" + + Column( + modifier = Modifier + .fillMaxWidth() + .quackBorder(border = QuackBorder(color = QuackColor.Gray4)) + .quackBorder( + border = QuackBorder( + color = if (isChecked) { + QuackColor.DuckieOrange + } else { + QuackColor.Gray4 + }, + ), + ) + .padding(12.dp), + ) { + Row( + modifier = Modifier.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + QuackRoundCheckBox( + modifier = Modifier.quackClickable( + onClick = { + setCorrectAnswerClick(if (isChecked) "" else "$answerIndex") + }, + ), + checked = isChecked, + ) + + if (isChecked) { + QuackText( + modifier = Modifier.padding(start = 2.dp), + typography = QuackTypography.Body3.change(QuackColor.DuckieOrange), + text = stringResource(id = R.string.answer), + ) + } + + Spacer(Modifier.weight(1f)) + + QuackIcon( + icon = OutlinedGroup.Close, + modifier = Modifier + .quackClickable( + onClick = { deleteLongClick(answerIndex) }, + ) + .size(DpSize(20.dp, 20.dp)), + ) + } + + if (answerItem.imageUrl.isEmpty()) { + Box( + modifier = Modifier + .quackClickable { answerImageClick(answerIndex) } + .background(color = QuackColor.Gray4.value) + .padding(52.dp), + ) { + QuackIcon( + modifier = Modifier.size(DpSize(32.dp, 32.dp)), + icon = OutlinedGroup.Image, + ) + } + } else { + QuackImage( + modifier = Modifier + .quackClickable( + onClick = { answerImageClick(answerIndex) }, + onLongClick = { deleteLongClick(answerIndex) }, + ) + .size(DpSize(136.dp, 136.dp)), + src = answerItem.imageUrl, + ) + } + + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + value = answers.imageChoice[answerIndex].text, + onValueChange = { newAnswer -> + answerTextChanged(newAnswer, answerIndex) + }, + placeholderText = stringResource( + id = R.string.create_problem_answer_placeholder, + "$answerNo", + ), + style = QuackTextFieldStyle.Default, + ) + } + }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + if (answers.imageChoice.size < MaximumChoice) { + QuackSubtitle( + modifier = Modifier + .quackClickable(onClick = addAnswerClick) + .padding(vertical = 2.dp, horizontal = 4.dp), + text = stringResource(id = R.string.create_problem_add_button), + ) + } + } +} diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ShortAnswerLayout.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ShortAnswerLayout.kt new file mode 100644 index 000000000..6cbd28087 --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ShortAnswerLayout.kt @@ -0,0 +1,65 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.feature.create.exam.common.type + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import team.duckie.app.android.domain.exam.model.Answer +import team.duckie.app.android.domain.exam.model.Question +import team.duckie.app.android.feature.create.exam.R +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +/** 주관식 Layout */ +@Composable +internal fun ShortAnswerLayout( + questionIndex: Int, + question: Question?, + titleChanged: (String) -> Unit, + imageClick: () -> Unit, + onDropdownItemClick: (Int) -> Unit, + answer: String, + answerTextChanged: (String, Int) -> Unit, + deleteLongClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .quackClickable( + onLongClick = { deleteLongClick() }, + ) {}, + ) { + TitleView( + questionIndex, + question, + titleChanged, + imageClick, + Answer.Type.ShortAnswer.title, + onDropdownItemClick, + ) + + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + value = answer, + onValueChange = { newAnswer -> answerTextChanged(newAnswer, 0) }, + placeholderText = stringResource(id = R.string.create_problem_short_answer_placeholder), + style = QuackTextFieldStyle.Default, + ) + } +} diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TextChoiceLayout.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TextChoiceLayout.kt new file mode 100644 index 000000000..348b80da0 --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TextChoiceLayout.kt @@ -0,0 +1,129 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn( + ExperimentalDesignToken::class, + ExperimentalDesignToken::class, + ExperimentalQuackQuackApi::class, +) + +package team.duckie.app.android.feature.create.exam.common.type + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.kotlin.fastForEachIndexed +import team.duckie.app.android.domain.exam.model.Answer +import team.duckie.app.android.domain.exam.model.Question +import team.duckie.app.android.feature.create.exam.R +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +/** 객관식/글 Layout */ +@Composable +@Suppress("unused") +internal fun TextChoiceLayout( + questionIndex: Int, + question: Question?, + titleChanged: (String) -> Unit, + imageClick: () -> Unit, + onDropdownItemClick: (Int) -> Unit, + answers: Answer.Choice, + answerTextChanged: (String, Int) -> Unit, + addAnswerClick: () -> Unit, + correctAnswers: String?, + setCorrectAnswerClick: (String) -> Unit, + deleteLongClick: (Int?) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .quackClickable( + onLongClick = { deleteLongClick(null) }, + ) {}, + ) { + TitleView( + questionIndex, + question, + titleChanged, + imageClick, + answers.type.title, + onDropdownItemClick, + ) + + answers.choices.fastForEachIndexed { answerIndex, choiceModel -> + val answerNo = answerIndex + 1 + val isChecked = correctAnswers == "$answerIndex" + QuackDefaultTextField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .quackBorder( + border = QuackBorder( + color = if (isChecked) QuackColor.DuckieOrange else QuackColor.Gray4, + ), + ) + .quackClickable( + onLongClick = { deleteLongClick(answerIndex) }, + ) {}, + value = choiceModel.text, + onValueChange = { newAnswer -> answerTextChanged(newAnswer, answerIndex) }, + placeholderText = stringResource( + id = R.string.create_problem_answer_placeholder, + "$answerNo", + ), + style = QuackTextFieldStyle.Default, + // TODO(riflockle7): 꽥꽥 기능 제공 안함 + // trailingContent = { + // Column( + // modifier = Modifier.quackClickable( + // onClick = { + // setCorrectAnswerClick(if (isChecked) "" else "$answerIndex") + // }, + // ), + // horizontalAlignment = Alignment.CenterHorizontally, + // ) { + // QuackRoundCheckBox(checked = isChecked) + // + // if (isChecked) { + // QuackText( + // modifier = Modifier.padding(top = 2.dp), + // typography = QuackTypography.Body3.change(QuackColor.DuckieOrange), + // text = stringResource(id = R.string.answer), + // ) + // } + // } + // }, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + if (answers.choices.size < MaximumChoice) { + QuackSubtitle( + modifier = Modifier + .quackClickable(onClick = addAnswerClick) + .padding(vertical = 2.dp, horizontal = 4.dp), + text = stringResource(id = R.string.create_problem_add_button), + ) + } + } +} diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TitleView.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TitleView.kt new file mode 100644 index 000000000..49c2761e1 --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TitleView.kt @@ -0,0 +1,74 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.feature.create.exam.common.type + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.QuackDropDownCard +import team.duckie.app.android.domain.exam.model.Question +import team.duckie.app.android.feature.create.exam.R +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Image +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +/** 문제 항목 Layout 내 공통 제목 Layout */ +@Composable +internal fun TitleView( + questionIndex: Int, + question: Question?, + titleChanged: (String) -> Unit, + imageClick: () -> Unit, + dropDownTitle: String, + onDropdownItemClick: (Int) -> Unit, +) { + // TODO(riflockle7): 동작 확인 필요 + // TODO(riflockle7): 최상단 Line 없는 TextField 필요 + QuackDefaultTextField( + modifier = Modifier.trailingIcon( + icon = OutlinedGroup.Image, + onClick = imageClick, + ), + value = question?.text ?: "", + onValueChange = titleChanged, + style = QuackTextFieldStyle.Default, + placeholderText = stringResource( + id = R.string.create_problem_question_placeholder, + "${questionIndex + 1}", + ), + ) + + (question as? Question.Image)?.imageUrl?.let { + QuackImage( + modifier = Modifier + .padding(top = 24.dp) + .size(DpSize(200.dp, 200.dp)), + src = it, + ) + } + + // TODO(riflockle7): border 없는 DropDownCard 필요 + QuackDropDownCard( + modifier = Modifier.padding(top = 24.dp), + text = dropDownTitle, + onClick = { + onDropdownItemClick(questionIndex) + }, + ) +} diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/impl/CreateProblemNavigatorImpl.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/impl/CreateProblemNavigatorImpl.kt similarity index 77% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/impl/CreateProblemNavigatorImpl.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/impl/CreateProblemNavigatorImpl.kt index 88ba06a47..ee75b324b 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/impl/CreateProblemNavigatorImpl.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/impl/CreateProblemNavigatorImpl.kt @@ -5,11 +5,11 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.navigator.impl +package team.duckie.app.android.feature.create.exam.navigator.impl import android.app.Activity import android.content.Intent -import team.duckie.app.android.feature.create.problem.CreateProblemActivity +import team.duckie.app.android.feature.create.exam.CreateExamActivity import team.duckie.app.android.navigator.feature.createproblem.CreateProblemNavigator import team.duckie.app.android.common.android.ui.startActivityWithAnimation import javax.inject.Inject @@ -20,7 +20,7 @@ internal class CreateProblemNavigatorImpl @Inject constructor() : CreateProblemN intentBuilder: Intent.() -> Intent, withFinish: Boolean, ) { - activity.startActivityWithAnimation( + activity.startActivityWithAnimation( intentBuilder = intentBuilder, withFinish = withFinish, ) diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/module/CreateProblemNavigatorModule.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/module/CreateProblemNavigatorModule.kt similarity index 79% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/module/CreateProblemNavigatorModule.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/module/CreateProblemNavigatorModule.kt index 474e2bb9d..a2d57397d 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/module/CreateProblemNavigatorModule.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/module/CreateProblemNavigatorModule.kt @@ -5,13 +5,13 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.navigator.module +package team.duckie.app.android.feature.create.exam.navigator.module import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import team.duckie.app.android.feature.create.problem.navigator.impl.CreateProblemNavigatorImpl +import team.duckie.app.android.feature.create.exam.navigator.impl.CreateProblemNavigatorImpl import team.duckie.app.android.navigator.feature.createproblem.CreateProblemNavigator import javax.inject.Singleton diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/AdditionalInfoScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/AdditionalInfoScreen.kt similarity index 82% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/AdditionalInfoScreen.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/AdditionalInfoScreen.kt index 2f8baf7bc..9ccd46b1c 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/AdditionalInfoScreen.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/AdditionalInfoScreen.kt @@ -6,10 +6,11 @@ */ @file:OptIn( ExperimentalMaterialApi::class, - ExperimentalComposeUiApi::class, + ExperimentalQuackQuackApi::class, + ExperimentalDesignToken::class, ) -package team.duckie.app.android.feature.create.problem.screen +package team.duckie.app.android.feature.create.exam.screen import android.Manifest import android.content.Context @@ -28,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -37,16 +39,13 @@ import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -62,35 +61,41 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.domain.exam.model.ThumbnailType -import team.duckie.app.android.domain.tag.model.Tag -import team.duckie.app.android.common.compose.ui.PhotoPicker -import team.duckie.app.android.feature.create.problem.R -import team.duckie.app.android.feature.create.problem.common.CreateProblemBottomLayout -import team.duckie.app.android.feature.create.problem.common.FadeAnimatedVisibility -import team.duckie.app.android.feature.create.problem.common.ImeActionNext -import team.duckie.app.android.feature.create.problem.common.PrevAndNextTopAppBar -import team.duckie.app.android.feature.create.problem.common.TitleAndComponent -import team.duckie.app.android.feature.create.problem.common.getCreateProblemMeasurePolicy -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemPhotoState -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemStep import team.duckie.app.android.common.compose.GetHeightRatioW328H240 +import team.duckie.app.android.common.compose.HideKeyboardWhenBottomSheetHidden import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.rememberToast import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.PhotoPicker +import team.duckie.app.android.common.compose.ui.icon.v1.AreaId +import team.duckie.app.android.common.compose.ui.quack.todo.QuackLazyVerticalGridTag import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.common.kotlin.takeBy -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBasicTextField -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackLazyVerticalGridTag -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.app.android.domain.exam.model.ThumbnailType +import team.duckie.app.android.domain.tag.model.Tag +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.CreateProblemBottomLayout +import team.duckie.app.android.feature.create.exam.common.FadeAnimatedVisibility +import team.duckie.app.android.feature.create.exam.common.ImeActionNext +import team.duckie.app.android.feature.create.exam.common.PrevAndNextTopAppBar +import team.duckie.app.android.feature.create.exam.common.TitleAndComponent +import team.duckie.app.android.feature.create.exam.common.getCreateProblemMeasurePolicy +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemPhotoState +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackButton +import team.duckie.quackquack.ui.QuackButtonStyle +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private const val TopAppBarLayoutId = "AdditionalInfoScreenTopAppBarLayoutId" private const val ContentLayoutId = "AdditionalInfoScreenContentLayoutId" @@ -153,24 +158,17 @@ internal fun AdditionalInformationScreen( sheetState.hide() } } else { - vm.navigateStep(CreateProblemStep.CreateProblem) + vm.navigateStep(CreateProblemStep.CreateExam) } } - LaunchedEffect(Unit) { - val sheetStateFlow = snapshotFlow { sheetState.currentValue } - sheetStateFlow.collect { state -> - if (state == ModalBottomSheetValue.Hidden) { - keyboard?.hide() - } - } - } + HideKeyboardWhenBottomSheetHidden(sheetState) ModalBottomSheetLayout( modifier = modifier, sheetState = sheetState, - sheetBackgroundColor = QuackColor.White.composeColor, - scrimColor = QuackColor.Dimmed.composeColor, + sheetBackgroundColor = QuackColor.White.value, + scrimColor = QuackColor.Dimmed.value, sheetShape = RoundedCornerShape( topStart = 16.dp, topEnd = 16.dp, @@ -189,7 +187,7 @@ internal fun AdditionalInformationScreen( .width(40.dp) .height(4.dp) .clip(RoundedCornerShape(2.dp)) - .background(QuackColor.Gray2.composeColor), + .background(QuackColor.Gray2.value), ) // 선택 목록 @@ -219,7 +217,7 @@ internal fun AdditionalInformationScreen( // 갤러리에서 선택 AdditionalBottomSheetThumbnailLayout( title = "", - src = team.duckie.quackquack.ui.R.drawable.quack_ic_area_24, + src = QuackIcon.AreaId, onClick = { val result = imagePermission.check(context) if (result) { @@ -247,7 +245,7 @@ internal fun AdditionalInformationScreen( // 상단 탭바 PrevAndNextTopAppBar( modifier = Modifier.layoutId(TopAppBarLayoutId), - onLeadingIconClick = { vm.navigateStep(CreateProblemStep.CreateProblem) }, + onLeadingIconClick = { vm.navigateStep(CreateProblemStep.CreateExam) }, ) // 컨텐츠 Layout @@ -297,7 +295,7 @@ internal fun AdditionalInformationScreen( modifier = Modifier .padding(top = systemBarPaddings.calculateTopPadding()) .fillMaxSize() - .background(color = QuackColor.White.composeColor), + .background(color = QuackColor.White.value), imageUris = galleryImages, imageSelections = galleryImagesSelections, onCameraClick = {}, @@ -330,6 +328,7 @@ internal fun AdditionalInformationScreen( } /** 썸네일 선택 (어떤 카테고리를 좋아하나요?) Layout */ +@OptIn(ExperimentalQuackQuackApi::class) @Composable private fun AdditionalThumbnailLayout( thumbnail: Any?, @@ -346,27 +345,29 @@ private fun AdditionalThumbnailLayout( stringResource = R.string.category_title, ) { QuackImage( - size = DpSize( - thumbnailWidthDp, - thumbnailWidthDp * GetHeightRatioW328H240, + modifier = Modifier.size( + DpSize( + thumbnailWidthDp, + thumbnailWidthDp * GetHeightRatioW328H240, + ), ), contentScale = ContentScale.FillWidth, src = thumbnail, ) // 썸네일 종류 선택 버튼 - // TODO(riflockle7): trailingIcon 추가 필요 - QuackLargeButton( - modifier = Modifier.padding(top = 4.dp), - type = QuackLargeButtonType.Border, + // TODO(riflockle7): 동작 확인 필요 + QuackButton( text = stringResource(id = R.string.additional_information_thumbnail_select), - leadingIcon = QuackIcon.ArrowRight, + style = QuackButtonStyle.PrimaryLarge, + modifier = Modifier.padding(top = 4.dp), onClick = onClick, ) } } /** 시험 응시 텍스트 선택 (시험 응시하기 버튼) Layout */ +@OptIn(ExperimentalDesignToken::class) @Composable private fun AdditionalTakeLayout(vm: CreateProblemViewModel = activityViewModel()) { val state = vm.collectAsState().value.additionalInfo @@ -375,9 +376,10 @@ private fun AdditionalTakeLayout(vm: CreateProblemViewModel = activityViewModel( modifier = Modifier.padding(top = 48.dp), stringResource = R.string.additional_information_take_title, ) { - QuackBasicTextField( - text = state.takeTitle, - onTextChanged = { + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + value = state.takeTitle, + onValueChange = { vm.setButtonTitle( it.takeBy( TakeTitleMaxLength, @@ -385,6 +387,7 @@ private fun AdditionalTakeLayout(vm: CreateProblemViewModel = activityViewModel( ), ) }, + style = QuackTextFieldStyle.Default, placeholderText = stringResource( id = R.string.additional_information_take_input_hint, TakeTitleMaxLength, @@ -403,12 +406,14 @@ private fun AdditionalSubTagsLayout(vm: CreateProblemViewModel = activityViewMod modifier = Modifier.padding(top = 48.dp), stringResource = R.string.additional_information_sub_tags_title, ) { - QuackBasicTextField( + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( modifier = Modifier.quackClickable { vm.goToSearchSubTags() }, - text = "", - onTextChanged = {}, + value = "", + onValueChange = { }, + style = QuackTextFieldStyle.Default, placeholderText = stringResource(id = R.string.additional_information_sub_tags_placeholder), enabled = false, ) @@ -419,7 +424,7 @@ private fun AdditionalSubTagsLayout(vm: CreateProblemViewModel = activityViewMod QuackLazyVerticalGridTag( horizontalSpace = 4.dp, items = state.subTags.fastMap(Tag::name), - tagType = QuackTagType.Circle(QuackIcon.Close), + trailingIcon = OutlinedGroup.Close, onClick = { vm.onClickCloseTag(it) }, itemChunkedSize = 3, ) @@ -445,9 +450,11 @@ private fun AdditionalBottomSheetThumbnailLayout( ), ) { QuackImage( - size = DpSize( - thumbnailWidthDp, - thumbnailWidthDp * GetHeightRatioW328H240, + modifier = Modifier.size( + DpSize( + thumbnailWidthDp, + thumbnailWidthDp * GetHeightRatioW328H240, + ), ), contentScale = ContentScale.FillWidth, src = src, diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/CreateProblemScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateExamScreen.kt similarity index 64% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/CreateProblemScreen.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateExamScreen.kt index d2153d38b..8d3eff819 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/CreateProblemScreen.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateExamScreen.kt @@ -7,10 +7,11 @@ @file:OptIn( ExperimentalMaterialApi::class, - ExperimentalComposeUiApi::class, + ExperimentalDesignToken::class, + ExperimentalQuackQuackApi::class, ) -package team.duckie.app.android.feature.create.problem.screen +package team.duckie.app.android.feature.create.exam.screen import android.Manifest import android.content.Context @@ -21,12 +22,9 @@ import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -41,16 +39,13 @@ import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.Layout @@ -59,57 +54,52 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.net.toUri import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.HideKeyboardWhenBottomSheetHidden import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.rememberToast import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.compose.ui.PhotoPicker import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog import team.duckie.app.android.common.kotlin.fastForEach -import team.duckie.app.android.common.kotlin.fastForEachIndexed import team.duckie.app.android.common.kotlin.takeBy import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.Question -import team.duckie.app.android.feature.create.problem.R -import team.duckie.app.android.feature.create.problem.common.CreateProblemBottomLayout -import team.duckie.app.android.feature.create.problem.common.NoLazyGridItems -import team.duckie.app.android.feature.create.problem.common.PrevAndNextTopAppBar -import team.duckie.app.android.feature.create.problem.common.getCreateProblemMeasurePolicy -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemPhotoState -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemStep -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.border.applyAnimatedQuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBasic2TextField -import team.duckie.quackquack.ui.component.QuackBasicTextField -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackBorderTextField -import team.duckie.quackquack.ui.component.QuackDropDownCard -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackRoundCheckBox -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.CreateProblemBottomLayout +import team.duckie.app.android.feature.create.exam.common.PrevAndNextTopAppBar +import team.duckie.app.android.feature.create.exam.common.getCreateProblemMeasurePolicy +import team.duckie.app.android.feature.create.exam.common.type.ImageChoiceLayout +import team.duckie.app.android.feature.create.exam.common.type.ShortAnswerLayout +import team.duckie.app.android.feature.create.exam.common.type.TextChoiceLayout +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemPhotoState +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Plus +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private const val TopAppBarLayoutId = "CreateProblemScreenTopAppBarLayoutId" private const val ContentLayoutId = "CreateProblemScreenContentLayoutId" private const val BottomLayoutId = "CreateProblemScreenBottomLayoutId" private const val GalleryListLayoutId = "CreateProblemScreenGalleryListLayoutId" -private const val MaximumChoice = 5 private const val MaximumProblem = 10 private const val TextFieldMaxLength = 20 /** 문제 만들기 2단계 (문제 만들기) Screen */ @Composable -internal fun CreateProblemScreen( +internal fun CreateExamScreen( modifier: Modifier, vm: CreateProblemViewModel = activityViewModel(), ) { @@ -117,7 +107,7 @@ internal fun CreateProblemScreen( val coroutineShape = rememberCoroutineScope() val rootState = vm.collectAsState().value - val state = rootState.createProblem + val state = rootState.createExam val keyboard = LocalSoftwareKeyboardController.current val sheetState = rememberModalBottomSheetState( ModalBottomSheetValue.Hidden, @@ -181,20 +171,13 @@ internal fun CreateProblemScreen( } } - LaunchedEffect(sheetState) { - val sheetStateFlow = snapshotFlow { sheetState.currentValue } - sheetStateFlow.collect { state -> - if (state == ModalBottomSheetValue.Hidden) { - keyboard?.hide() - } - } - } + HideKeyboardWhenBottomSheetHidden(sheetState) ModalBottomSheetLayout( modifier = modifier, sheetState = sheetState, - sheetBackgroundColor = QuackColor.White.composeColor, - scrimColor = QuackColor.Dimmed.composeColor, + sheetBackgroundColor = QuackColor.White.value, + scrimColor = QuackColor.Dimmed.value, sheetShape = RoundedCornerShape( topStart = 16.dp, topEnd = 16.dp, @@ -213,7 +196,7 @@ internal fun CreateProblemScreen( .width(40.dp) .height(4.dp) .clip(RoundedCornerShape(2.dp)) - .background(QuackColor.Gray2.composeColor), + .background(QuackColor.Gray2.value), ) // 선택 목록 @@ -223,26 +206,30 @@ internal fun CreateProblemScreen( .padding(top = 16.dp), ) { buttonNames.fastForEach { - QuackSubtitle( - modifier = Modifier.fillMaxWidth(), - padding = PaddingValues( - vertical = 12.dp, - horizontal = 16.dp, - ), - text = it.first, - onClick = { - coroutineShape.launch { - selectedQuestionIndex?.let { questionIndex -> - // 특정 문제의 답안 유형 수정 - vm.editAnswersType(questionIndex, it.second) - selectedQuestionIndex = null - } ?: run { - // 문제 추가 - vm.addProblem(it.second) + QuackText( + modifier = Modifier + .quackClickable { + coroutineShape.launch { + selectedQuestionIndex?.let { questionIndex -> + // 특정 문제의 답안 유형 수정 + vm.editAnswersType(questionIndex, it.second) + selectedQuestionIndex = null + } ?: run { + // 문제 추가 + vm.addProblem(it.second) + } + hideBottomSheet(sheetState) { selectedQuestionIndex = null } } - hideBottomSheet(sheetState) { selectedQuestionIndex = null } } - }, + .fillMaxWidth() + .padding( + PaddingValues( + vertical = 12.dp, + horizontal = 16.dp, + ), + ), + text = it.first, + typography = QuackTypography.Subtitle, ) } } @@ -285,7 +272,7 @@ internal fun CreateProblemScreen( val correctAnswer = state.correctAnswers[questionIndex] when (answers) { - is Answer.Short -> ShortAnswerProblemLayout( + is Answer.Short -> ShortAnswerLayout( questionIndex = questionIndex, question = question, titleChanged = { newTitle -> @@ -334,7 +321,7 @@ internal fun CreateProblemScreen( }, ) - is Answer.Choice -> ChoiceProblemLayout( + is Answer.Choice -> TextChoiceLayout( questionIndex = questionIndex, question = question, titleChanged = { newTitle -> @@ -396,7 +383,7 @@ internal fun CreateProblemScreen( }, ) - is Answer.ImageChoice -> ImageChoiceProblemLayout( + is Answer.ImageChoice -> ImageChoiceLayout( questionIndex = questionIndex, question = question, titleChanged = { newTitle -> @@ -487,7 +474,7 @@ internal fun CreateProblemScreen( modifier = Modifier .fillMaxWidth() .layoutId(BottomLayoutId), - leftButtonLeadingIcon = QuackIcon.Plus, + leftButtonLeadingIcon = OutlinedGroup.Plus, leftButtonText = stringResource(id = R.string.create_problem_add_problem_button), leftButtonClick = { coroutineShape.launch { @@ -504,7 +491,7 @@ internal fun CreateProblemScreen( } }, isCreateProblemValidate = problemCount < MaximumProblem, - isValidateCheck = vm::createProblemIsValidate, + isValidateCheck = vm::createExamIsValidate, ) }, ) @@ -516,7 +503,7 @@ internal fun CreateProblemScreen( modifier = Modifier .padding(top = systemBarPaddings.calculateTopPadding()) .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .layoutId(GalleryListLayoutId), imageUris = galleryImages, imageSelections = galleryImagesSelections, @@ -605,327 +592,20 @@ private fun CoroutineScope.hideBottomSheet( private fun CoroutineScope.openPhotoPicker( context: Context, vm: CreateProblemViewModel, - createProblemPhotoState: CreateProblemPhotoState, + createExamPhotoState: CreateProblemPhotoState, keyboard: SoftwareKeyboardController?, launcher: ManagedActivityResultLauncher, ) = launch { val result = imagePermission.check(context) if (result) { vm.loadGalleryImages() - vm.updatePhotoState(createProblemPhotoState) + vm.updatePhotoState(createExamPhotoState) keyboard?.hide() } else { launcher.launch(imagePermission) } } -/** 문제 항목 Layout 내 공통 제목 Layout */ -@Composable -private fun CreateProblemTitleLayout( - questionIndex: Int, - question: Question?, - titleChanged: (String) -> Unit, - imageClick: () -> Unit, - dropDownTitle: String, - onDropdownItemClick: (Int) -> Unit, -) { - // TODO(riflockle7): 최상단 Line 없는 TextField 필요 - QuackBasic2TextField( - text = question?.text ?: "", - onTextChanged = titleChanged, - placeholderText = stringResource( - id = R.string.create_problem_question_placeholder, - "${questionIndex + 1}", - ), - trailingIcon = QuackIcon.Image, - trailingIconOnClick = imageClick, - ) - - (question as? Question.Image)?.imageUrl?.let { - QuackImage( - modifier = Modifier.padding(top = 24.dp), - src = it, - size = DpSize(200.dp, 200.dp), - ) - } - - // TODO(riflockle7): border 없는 DropDownCard 필요 - QuackDropDownCard( - modifier = Modifier.padding(top = 24.dp), - text = dropDownTitle, - onClick = { - onDropdownItemClick(questionIndex) - }, - ) -} - -/** 객관식/글 Layout */ -@Composable -private fun ChoiceProblemLayout( - questionIndex: Int, - question: Question?, - titleChanged: (String) -> Unit, - imageClick: () -> Unit, - onDropdownItemClick: (Int) -> Unit, - answers: Answer.Choice, - answerTextChanged: (String, Int) -> Unit, - addAnswerClick: () -> Unit, - correctAnswers: String?, - setCorrectAnswerClick: (String) -> Unit, - deleteLongClick: (Int?) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .quackClickable( - onLongClick = { deleteLongClick(null) }, - ) {}, - ) { - CreateProblemTitleLayout( - questionIndex, - question, - titleChanged, - imageClick, - answers.type.title, - onDropdownItemClick, - ) - - answers.choices.fastForEachIndexed { answerIndex, choiceModel -> - val answerNo = answerIndex + 1 - val isChecked = correctAnswers == "$answerIndex" - QuackBorderTextField( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - .applyAnimatedQuackBorder( - border = QuackBorder( - width = 1.dp, - color = if (isChecked) QuackColor.DuckieOrange else QuackColor.Gray4, - ), - ) - .quackClickable( - onLongClick = { deleteLongClick(answerIndex) }, - ) {}, - text = choiceModel.text, - onTextChanged = { newAnswer -> answerTextChanged(newAnswer, answerIndex) }, - placeholderText = stringResource( - id = R.string.create_problem_answer_placeholder, - "$answerNo", - ), - trailingContent = { - Column( - modifier = Modifier.quackClickable( - onClick = { - setCorrectAnswerClick(if (isChecked) "" else "$answerIndex") - }, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - QuackRoundCheckBox(checked = isChecked) - - if (isChecked) { - QuackBody3( - modifier = Modifier.padding(top = 2.dp), - color = QuackColor.DuckieOrange, - text = stringResource(id = R.string.answer), - ) - } - } - }, - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - if (answers.choices.size < MaximumChoice) { - QuackSubtitle( - modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp), - text = stringResource(id = R.string.create_problem_add_button), - onClick = { addAnswerClick() }, - ) - } - } -} - -/** - * 객관식/사진 Layout - * // TODO(riflockle7): 정답 체크 연동 필요 - */ -@Composable -private fun ImageChoiceProblemLayout( - questionIndex: Int, - question: Question?, - titleChanged: (String) -> Unit, - imageClick: () -> Unit, - onDropdownItemClick: (Int) -> Unit, - answers: Answer.ImageChoice, - answerTextChanged: (String, Int) -> Unit, - answerImageClick: (Int) -> Unit, - addAnswerClick: () -> Unit, - correctAnswers: String?, - setCorrectAnswerClick: (String) -> Unit, - deleteLongClick: (Int?) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .quackClickable( - onLongClick = { deleteLongClick(null) }, - ) {}, - ) { - CreateProblemTitleLayout( - questionIndex, - question, - titleChanged, - imageClick, - answers.type.title, - onDropdownItemClick, - ) - - NoLazyGridItems( - count = answers.imageChoice.size, - nColumns = 2, - paddingValues = PaddingValues(top = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - itemContent = { answerIndex -> - val answerNo = answerIndex + 1 - val answerItem = answers.imageChoice[answerIndex] - val isChecked = correctAnswers == "$answerIndex" - - Column( - modifier = Modifier - .fillMaxWidth() - .applyAnimatedQuackBorder(border = QuackBorder(color = QuackColor.Gray4)) - .applyAnimatedQuackBorder( - border = QuackBorder( - width = 1.dp, - color = if (isChecked) { - QuackColor.DuckieOrange - } else { - QuackColor.Gray4 - }, - ), - ) - .padding(12.dp), - ) { - Row( - modifier = Modifier.padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - QuackRoundCheckBox( - modifier = Modifier.quackClickable( - onClick = { - setCorrectAnswerClick(if (isChecked) "" else "$answerIndex") - }, - ), - checked = isChecked, - ) - - if (isChecked) { - QuackBody3( - modifier = Modifier.padding(start = 2.dp), - color = QuackColor.DuckieOrange, - text = stringResource(id = R.string.answer), - ) - } - - Spacer(Modifier.weight(1f)) - - QuackImage( - modifier = Modifier.quackClickable( - onClick = { deleteLongClick(answerIndex) }, - ), - src = QuackIcon.Close, - size = DpSize(20.dp, 20.dp), - ) - } - - if (answerItem.imageUrl.isEmpty()) { - Box( - modifier = Modifier - .quackClickable { answerImageClick(answerIndex) } - .background(color = QuackColor.Gray4.composeColor) - .padding(52.dp), - ) { - QuackImage( - src = QuackIcon.Image, - size = DpSize(32.dp, 32.dp), - ) - } - } else { - QuackImage( - src = answerItem.imageUrl, - size = DpSize(136.dp, 136.dp), - onClick = { answerImageClick(answerIndex) }, - onLongClick = { deleteLongClick(answerIndex) }, - ) - } - - QuackBasicTextField( - text = answers.imageChoice[answerIndex].text, - onTextChanged = { newAnswer -> - answerTextChanged(newAnswer, answerIndex) - }, - placeholderText = stringResource( - id = R.string.create_problem_answer_placeholder, - "$answerNo", - ), - ) - } - }, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - if (answers.imageChoice.size < MaximumChoice) { - QuackSubtitle( - modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp), - text = stringResource(id = R.string.create_problem_add_button), - onClick = { addAnswerClick() }, - ) - } - } -} - -/** 주관식 Layout */ -@Composable -private fun ShortAnswerProblemLayout( - questionIndex: Int, - question: Question?, - titleChanged: (String) -> Unit, - imageClick: () -> Unit, - onDropdownItemClick: (Int) -> Unit, - answer: String, - answerTextChanged: (String, Int) -> Unit, - deleteLongClick: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .quackClickable( - onLongClick = { deleteLongClick() }, - ) {}, - ) { - CreateProblemTitleLayout( - questionIndex, - question, - titleChanged, - imageClick, - Answer.Type.ShortAnswer.title, - onDropdownItemClick, - ) - - QuackBasicTextField( - text = answer, - onTextChanged = { newAnswer -> answerTextChanged(newAnswer, 0) }, - placeholderText = stringResource(id = R.string.create_problem_short_answer_placeholder), - ) - } -} - /** * 이미지 권한 체크시 사용해야하는 permission * TODO(riflockle7): 권한 로직은 추후 PermissionViewModel 과 같이 쓰면서 지워질 예정 diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateProblemScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateProblemScreen.kt new file mode 100644 index 000000000..894183ecf --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateProblemScreen.kt @@ -0,0 +1,42 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.feature.create.exam.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.launch +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.PrevAndNextTopAppBar +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep + +/** 단일 문제 만들기 Screen */ +@Composable +internal fun CreateProblemScreen( + modifier: Modifier, + vm: CreateProblemViewModel = activityViewModel(), +) { + val coroutineScope = rememberCoroutineScope() + + Column(modifier = modifier) { + PrevAndNextTopAppBar( + modifier = Modifier.fillMaxWidth(), + onLeadingIconClick = { + coroutineScope.launch { vm.navigateStep(CreateProblemStep.ExamInformation) } + }, + trailingText = stringResource(id = R.string.next), + onTrailingTextClick = {}, + trailingTextEnabled = true, + ) + } +} diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/ExamInformationScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/ExamInformationScreen.kt similarity index 68% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/ExamInformationScreen.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/ExamInformationScreen.kt index 08c9cffa6..5ba6dc160 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/ExamInformationScreen.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/ExamInformationScreen.kt @@ -5,11 +5,14 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.screen +@file:OptIn(ExperimentalQuackQuackApi::class, ExperimentalDesignToken::class) + +package team.duckie.app.android.feature.create.exam.screen import android.app.Activity import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -36,7 +39,6 @@ import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -44,27 +46,31 @@ import org.orbitmvi.orbit.compose.collectAsState import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.ui.DuckieGridLayout import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSurface import team.duckie.app.android.common.kotlin.takeBy -import team.duckie.app.android.feature.create.problem.R -import team.duckie.app.android.feature.create.problem.common.CreateProblemBottomLayout -import team.duckie.app.android.feature.create.problem.common.ImeActionNext -import team.duckie.app.android.feature.create.problem.common.PrevAndNextTopAppBar -import team.duckie.app.android.feature.create.problem.common.TitleAndComponent -import team.duckie.app.android.feature.create.problem.common.getCreateProblemMeasurePolicy -import team.duckie.app.android.feature.create.problem.common.moveDownFocus -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBasicTextField -import team.duckie.quackquack.ui.component.QuackCircleTag -import team.duckie.quackquack.ui.component.QuackGrayscaleTextField -import team.duckie.quackquack.ui.component.QuackReviewTextArea -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.CreateProblemBottomLayout +import team.duckie.app.android.feature.create.exam.common.ImeActionNext +import team.duckie.app.android.feature.create.exam.common.PrevAndNextTopAppBar +import team.duckie.app.android.feature.create.exam.common.TitleAndComponent +import team.duckie.app.android.feature.create.exam.common.getCreateProblemMeasurePolicy +import team.duckie.app.android.feature.create.exam.common.moveDownFocus +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.QuackTextArea +import team.duckie.quackquack.ui.QuackTextAreaStyle +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private const val TopAppBarLayoutId = "ExamInformationScreenTopAppBarLayoutId" private const val ContentLayoutId = "ExamInformationScreenContentLayoutId" @@ -84,12 +90,12 @@ internal fun ExamInformationScreen( val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current val lazyListState = rememberLazyListState() - var createProblemExitDialogVisible by remember { mutableStateOf(false) } + var createExamExitDialogVisible by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } BackHandler { - if (!createProblemExitDialogVisible) { - createProblemExitDialogVisible = true + if (!createExamExitDialogVisible) { + createExamExitDialogVisible = true } } @@ -115,8 +121,8 @@ internal fun ExamInformationScreen( modifier = Modifier.layoutId(TopAppBarLayoutId), trailingText = stringResource(id = R.string.next), onLeadingIconClick = { - if (!createProblemExitDialogVisible) { - createProblemExitDialogVisible = true + if (!createExamExitDialogVisible) { + createExamExitDialogVisible = true } }, ) @@ -147,30 +153,34 @@ internal fun ExamInformationScreen( } TitleAndComponent(stringResource = R.string.main_tag) { if (state.isMainTagSelected) { - QuackCircleTag( + QuackTag( text = state.mainTag, - trailingIcon = QuackIcon.Close, - isSelected = false, - onClick = { viewModel.onClickCloseTag() }, - ) + style = QuackTagStyle.Outlined, + modifier = Modifier.trailingIcon(OutlinedGroup.Close) { viewModel.onClickCloseTag() }, + selected = false, + ) {} } - QuackAnimatedVisibility(visible = !state.isMainTagSelected) { - QuackBasicTextField( - modifier = Modifier.quackClickable { - viewModel.goToSearchMainTag(lazyListState.firstVisibleItemIndex) - }, - leadingIcon = QuackIcon.Search, - text = state.mainTag, - onTextChanged = {}, + AnimatedVisibility(visible = !state.isMainTagSelected) { + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + modifier = Modifier.quackClickable( + onClick = { + viewModel.goToSearchMainTag(lazyListState.firstVisibleItemIndex) + }, + ), + // leadingIcon = QuackIcon.Search, + value = state.mainTag, + onValueChange = {}, placeholderText = stringResource(id = R.string.search_main_tag_placeholder), + style = QuackTextFieldStyle.Default, enabled = false, ) } } TitleAndComponent(stringResource = R.string.exam_title) { - QuackBasicTextField( - text = state.examTitle, - onTextChanged = { + QuackDefaultTextField( + value = state.examTitle, + onValueChange = { viewModel.setExamTitle( it.takeBy( ExamTitleMaxLength, @@ -178,6 +188,7 @@ internal fun ExamInformationScreen( ), ) }, + style = QuackTextFieldStyle.Default, placeholderText = stringResource( id = R.string.input_exam_title, ExamTitleMaxLength, @@ -187,15 +198,17 @@ internal fun ExamInformationScreen( ) } TitleAndComponent(stringResource = R.string.exam_description) { - QuackReviewTextArea( + // TODO(riflockle7): 동작 확인 필요 + QuackTextArea( modifier = Modifier .heightIn(140.dp) .focusRequester(focusRequester = focusRequester) .onFocusChanged { state -> viewModel.onSearchTextFocusChanged(state.isFocused) }, - text = state.examDescription, - onTextChanged = { + value = state.examDescription, + style = QuackTextAreaStyle.Default, + onValueChange = { viewModel.setExamDescription( it.takeBy( ExamDescriptionMaxLength, @@ -207,16 +220,17 @@ internal fun ExamInformationScreen( id = R.string.input_exam_description, ExamDescriptionMaxLength, ), - imeAction = ImeAction.Next, - keyboardActions = moveDownFocus(focusManager), - focused = state.examDescriptionFocused, + // TODO(riflockle7): 꽥꽥에서 기능 제공 안함 + // imeAction = ImeAction.Next, + // keyboardActions = moveDownFocus(focusManager), + // focused = state.examDescriptionFocused, ) } TitleAndComponent(stringResource = R.string.certifying_statement) { - QuackGrayscaleTextField( + QuackDefaultTextField( modifier = Modifier.padding(bottom = 16.dp), - text = state.certifyingStatement, - onTextChanged = { + value = state.certifyingStatement, + onValueChange = { viewModel.setCertifyingStatement( it.takeBy( CertifyingStatementMaxLength, @@ -224,6 +238,7 @@ internal fun ExamInformationScreen( ), ) }, + style = QuackTextFieldStyle.Default, placeholderText = stringResource( id = R.string.input_certifying_statement, CertifyingStatementMaxLength, @@ -236,8 +251,9 @@ internal fun ExamInformationScreen( } }, ), - maxLength = CertifyingStatementMaxLength, - showCounter = true, + // TODO(riflockle7): 꽥꽥에서 기능 제공 안함 + // maxLength = CertifyingStatementMaxLength, + // showCounter = true, ) } } @@ -264,15 +280,15 @@ internal fun ExamInformationScreen( DuckieDialog( title = stringResource(id = R.string.create_problem_exit_dialog_title), message = stringResource(id = R.string.create_problem_exit_dialog_message), - visible = createProblemExitDialogVisible, + visible = createExamExitDialogVisible, leftButtonText = stringResource(id = R.string.cancel), - leftButtonOnClick = { createProblemExitDialogVisible = false }, + leftButtonOnClick = { createExamExitDialogVisible = false }, rightButtonText = stringResource(id = R.string.ok), rightButtonOnClick = { - createProblemExitDialogVisible = false + createExamExitDialogVisible = false activity.finish() }, - onDismissRequest = { createProblemExitDialogVisible = false }, + onDismissRequest = { createExamExitDialogVisible = false }, ) } @@ -289,11 +305,12 @@ private fun MediumButton( height = 40.dp, ), backgroundColor = QuackColor.White, - border = QuackBorder( - color = when (selected) { + border = BorderStroke( + width = 1.dp, + brush = when (selected) { true -> QuackColor.DuckieOrange else -> QuackColor.Gray3 - }, + }.toBrush(), ), shape = RoundedCornerShape(size = 8.dp), onClick = onClick, @@ -301,13 +318,13 @@ private fun MediumButton( QuackText( modifier = Modifier.padding(all = 10.dp), text = text, - style = when (selected) { - true -> QuackTextStyle.Title2.change( + typography = when (selected) { + true -> QuackTypography.Title2.change( color = QuackColor.DuckieOrange, textAlign = TextAlign.Center, ) - else -> QuackTextStyle.Body1.change( + else -> QuackTypography.Body1.change( color = QuackColor.Black, textAlign = TextAlign.Center, ) diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/SearchTagScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/SearchTagScreen.kt similarity index 81% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/SearchTagScreen.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/SearchTagScreen.kt index 4898ca53a..ea771418f 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/SearchTagScreen.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/SearchTagScreen.kt @@ -5,9 +5,12 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.screen +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.feature.create.exam.screen import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -30,21 +33,23 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.create.problem.R -import team.duckie.app.android.feature.create.problem.common.CreateProblemBottomLayout -import team.duckie.app.android.feature.create.problem.common.ExitAppBar -import team.duckie.app.android.feature.create.problem.common.SearchResultText -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.state.FindResultType -import team.duckie.app.android.feature.create.problem.viewmodel.state.SearchScreenData -import team.duckie.app.android.common.compose.ui.ImeSpacer import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.ImeSpacer +import team.duckie.app.android.common.compose.ui.quack.todo.QuackLazyVerticalGridTag import team.duckie.app.android.common.kotlin.fastMap -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.component.QuackBasicTextField -import team.duckie.quackquack.ui.component.QuackLazyVerticalGridTag -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.CreateProblemBottomLayout +import team.duckie.app.android.feature.create.exam.common.ExitAppBar +import team.duckie.app.android.feature.create.exam.common.SearchResultText +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.FindResultType +import team.duckie.app.android.feature.create.exam.viewmodel.state.SearchScreenData +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private const val MaximumSubTagCount = 5 @@ -111,13 +116,16 @@ internal fun SearchTagScreen( ), horizontalSpace = 4.dp, items = state.results.fastMap { it.name }, - tagType = QuackTagType.Circle(QuackIcon.Close), + trailingIcon = OutlinedGroup.Close, onClick = { viewModel.onClickCloseTag(it) }, itemChunkedSize = 3, ) } - QuackBasicTextField( + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + // TODO(riflockle7): 꽥꽥 기능 제공 안함 + // leadingIcon = QuackIcon.Search, modifier = Modifier .padding( top = 16.dp, @@ -125,11 +133,11 @@ internal fun SearchTagScreen( end = 16.dp, ) .focusRequester(focusRequester), - leadingIcon = QuackIcon.Search, - text = searchTextFieldValue, - onTextChanged = { textFieldValue -> + value = searchTextFieldValue, + onValueChange = { textFieldValue -> viewModel.setTextFieldValue(textFieldValue = textFieldValue) }, + style = QuackTextFieldStyle.Default, placeholderText = placeholderText, keyboardActions = KeyboardActions( onDone = { @@ -140,7 +148,7 @@ internal fun SearchTagScreen( ), ) - QuackAnimatedVisibility( + AnimatedVisibility( modifier = Modifier.padding( top = 8.dp, start = 16.dp, diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/CreateProblemViewModel.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/CreateProblemViewModel.kt similarity index 92% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/CreateProblemViewModel.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/CreateProblemViewModel.kt index 38ad139ad..b530ee80f 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/CreateProblemViewModel.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/CreateProblemViewModel.kt @@ -7,7 +7,7 @@ @file:Suppress("ConstPropertyName", "PrivatePropertyName") -package team.duckie.app.android.feature.create.problem.viewmodel +package team.duckie.app.android.feature.create.exam.viewmodel import android.app.Application import android.content.Context @@ -31,6 +31,16 @@ import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import team.duckie.app.android.common.android.image.MediaUtil +import team.duckie.app.android.common.android.network.NetworkUtil +import team.duckie.app.android.common.android.ui.const.Debounce +import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.common.android.viewmodel.context +import team.duckie.app.android.common.kotlin.copy +import team.duckie.app.android.common.kotlin.exception.duckieClientLogicProblemException +import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe +import team.duckie.app.android.common.kotlin.exception.isTagAlreadyExist +import team.duckie.app.android.common.kotlin.fastMapIndexed import team.duckie.app.android.domain.category.usecase.GetCategoriesUseCase import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.ChoiceModel @@ -55,21 +65,11 @@ import team.duckie.app.android.domain.search.usecase.GetSearchUseCase import team.duckie.app.android.domain.tag.model.Tag import team.duckie.app.android.domain.tag.repository.TagRepository import team.duckie.app.android.domain.user.usecase.GetMeUseCase -import team.duckie.app.android.feature.create.problem.viewmodel.sideeffect.CreateProblemSideEffect -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemPhotoState -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemState -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemStep -import team.duckie.app.android.feature.create.problem.viewmodel.state.FindResultType -import team.duckie.app.android.common.android.image.MediaUtil -import team.duckie.app.android.common.android.network.NetworkUtil -import team.duckie.app.android.common.android.viewmodel.context -import team.duckie.app.android.common.kotlin.copy -import team.duckie.app.android.common.kotlin.exception.duckieClientLogicProblemException -import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe -import team.duckie.app.android.common.kotlin.exception.isTagAlreadyExist -import team.duckie.app.android.common.kotlin.fastMapIndexed -import team.duckie.app.android.common.android.ui.const.Debounce -import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.feature.create.exam.viewmodel.sideeffect.CreateProblemSideEffect +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemPhotoState +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemState +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep +import team.duckie.app.android.feature.create.exam.viewmodel.state.FindResultType import javax.inject.Inject private const val TagsMaximumCount = 10 @@ -111,7 +111,7 @@ internal class CreateProblemViewModel @Inject constructor( state.copy( me = me, isEditMode = false, - createProblemStep = CreateProblemStep.ExamInformation, + createExamStep = CreateProblemStep.ExamInformation, ) } }.onFailure { @@ -130,7 +130,7 @@ internal class CreateProblemViewModel @Inject constructor( private suspend fun loadErrorPage(isNetworkError: Boolean = false) = intent { reduce { state.copy( - createProblemStep = CreateProblemStep.Error, + createExamStep = CreateProblemStep.Error, isNetworkError = isNetworkError, ) } @@ -141,7 +141,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( isNetworkError = false, - createProblemStep = CreateProblemStep.Loading, + createExamStep = CreateProblemStep.Loading, ) } } @@ -236,21 +236,21 @@ internal class CreateProblemViewModel @Inject constructor( /** request 를 위해 필요한 [ExamBody] 를 생성한다. */ private fun generateExamBody(): ExamBody { val examInformationState = container.stateFlow.value.examInformation - val createProblemState = container.stateFlow.value.createProblem + val createExamState = container.stateFlow.value.createExam val additionalInfoState = container.stateFlow.value.additionalInfo val serverCorrectAnswers = - createProblemState.correctAnswers.fastMapIndexed { index, correctAnswer -> - correctAnswer.toCorrectAnswerData(createProblemState.answers[index]) + createExamState.correctAnswers.fastMapIndexed { index, correctAnswer -> + correctAnswer.toCorrectAnswerData(createExamState.answers[index]) } - val problems = createProblemState.questions.fastMapIndexed { index, question -> + val problems = createExamState.questions.fastMapIndexed { index, question -> Problem( index, question, - createProblemState.answers[index], + createExamState.answers[index], serverCorrectAnswers[index], - createProblemState.hints[index], - createProblemState.memos[index], + createExamState.hints[index], + createExamState.memos[index], null, ) }.toPersistentList() @@ -284,7 +284,7 @@ internal class CreateProblemViewModel @Inject constructor( /** 특정 태그의 닫기 버튼을 클릭한다. 대체로 삭제 로직이 실행된다. */ internal fun onClickCloseTag(index: Int = 0) = intent { reduce { - when (state.createProblemStep) { + when (state.createExamStep) { CreateProblemStep.ExamInformation -> { state.copy( examInformation = state.examInformation.run { @@ -358,7 +358,7 @@ internal class CreateProblemViewModel @Inject constructor( /** 특정 화면으로 이동한다. */ internal fun navigateStep(step: CreateProblemStep) = intent { reduce { - state.copy(createProblemStep = step) + state.copy(createExamStep = step) } } @@ -394,7 +394,7 @@ internal class CreateProblemViewModel @Inject constructor( internal fun goToSearchMainTag(scrollPosition: Int) = intent { reduce { state.copy( - createProblemStep = CreateProblemStep.Search, + createExamStep = CreateProblemStep.Search, findResultType = FindResultType.MainTag, examInformation = state.examInformation.copy( scrollPosition = scrollPosition, @@ -453,7 +453,7 @@ internal class CreateProblemViewModel @Inject constructor( // 이전에 등록된 제목과 동일한 경우 별다른 처리를 하지 않는다. if (state.examInformation.prevExamTitle == state.examInformation.examTitle) { reduce { - state.copy(createProblemStep = CreateProblemStep.CreateProblem) + state.copy(createExamStep = CreateProblemStep.CreateExam) } return@intent } @@ -463,7 +463,7 @@ internal class CreateProblemViewModel @Inject constructor( ).onSuccess { thumbnail -> reduce { state.copy( - createProblemStep = CreateProblemStep.CreateProblem, + createExamStep = CreateProblemStep.CreateExam, examInformation = state.examInformation.copy( prevExamTitle = state.examInformation.examTitle, ), @@ -490,7 +490,7 @@ internal class CreateProblemViewModel @Inject constructor( val newQuestion = Question.Text(text = "") val newAnswer = answerType.getDefaultAnswer() - with(state.createProblem) { + with(state.createExam) { val newQuestions = questions.copy { add(newQuestion) } val newAnswers = answers.copy { add(newAnswer) } val newCorrectAnswers = correctAnswers.copy { add("") } @@ -499,7 +499,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( questions = newQuestions.toPersistentList(), answers = newAnswers.toPersistentList(), correctAnswers = newCorrectAnswers.toPersistentList(), @@ -513,7 +513,7 @@ internal class CreateProblemViewModel @Inject constructor( /** [questionIndex + 1] 번 문제를 삭제한다. */ internal fun removeProblem(questionIndex: Int) = intent { - with(state.createProblem) { + with(state.createExam) { val newQuestions = questions.copy { removeAt(questionIndex) } val newAnswers = answers.copy { removeAt(questionIndex) } val newCorrectAnswers = correctAnswers.copy { removeAt(questionIndex) } @@ -522,7 +522,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( questions = newQuestions.toPersistentList(), answers = newAnswers.toPersistentList(), correctAnswers = newCorrectAnswers.toPersistentList(), @@ -546,7 +546,7 @@ internal class CreateProblemViewModel @Inject constructor( title: String? = null, urlSource: String? = null, ) = intent { - val newQuestions = state.createProblem.questions.toMutableList() + val newQuestions = state.createExam.questions.toMutableList() val prevQuestion = newQuestions[questionIndex] val newQuestion = when (questionType) { Question.Type.Text -> Question.Text( @@ -574,7 +574,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( questions = newQuestions.toPersistentList(), ), ) @@ -617,7 +617,7 @@ internal class CreateProblemViewModel @Inject constructor( questionIndex: Int, answerType: Answer.Type, ) = intent { - val newAnswers = state.createProblem.answers.toMutableList() + val newAnswers = state.createExam.answers.toMutableList() newAnswers[questionIndex] = when (answerType) { Answer.Type.ShortAnswer -> newAnswers[questionIndex].toShort() Answer.Type.Choice -> newAnswers[questionIndex].toChoice() @@ -626,7 +626,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( answers = newAnswers.toPersistentList(), ), ) @@ -647,7 +647,7 @@ internal class CreateProblemViewModel @Inject constructor( answer: String? = null, urlSource: String? = null, ) = intent { - val newAnswers = state.createProblem.answers.toMutableList() + val newAnswers = state.createExam.answers.toMutableList() newAnswers[questionIndex].getEditedAnswers( answerIndex, @@ -656,14 +656,14 @@ internal class CreateProblemViewModel @Inject constructor( urlSource, ).let { newAnswers[questionIndex] = it } - val newCorrectAnswers = state.createProblem.correctAnswers.toMutableList() + val newCorrectAnswers = state.createExam.correctAnswers.toMutableList() if (answerType == Answer.Type.ShortAnswer) { newCorrectAnswers[questionIndex] = answer ?: "" } reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( answers = newAnswers.toPersistentList(), correctAnswers = newCorrectAnswers.toPersistentList(), ), @@ -699,7 +699,7 @@ internal class CreateProblemViewModel @Inject constructor( questionIndex: Int, answerIndex: Int, ) = intent { - val newAnswers = state.createProblem.answers.toMutableList() + val newAnswers = state.createExam.answers.toMutableList() val newAnswer = newAnswers[questionIndex] newAnswers[questionIndex] = when (newAnswer) { is Answer.Short -> duckieClientLogicProblemException(message = "주관식 답변은 삭제할 수 없습니다.") @@ -714,14 +714,14 @@ internal class CreateProblemViewModel @Inject constructor( } // 만약 정답 처리 된 내용을 삭제하는 경우 정답내용(여기에서는 정답 index)를 초기화 시킨다. - val newCorrectAnswers = state.createProblem.correctAnswers.toMutableList() + val newCorrectAnswers = state.createExam.correctAnswers.toMutableList() if ("$answerIndex" == newCorrectAnswers[questionIndex]) { newCorrectAnswers[questionIndex] = "" } reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( answers = newAnswers.toPersistentList(), correctAnswers = newCorrectAnswers.toPersistentList(), ), @@ -752,12 +752,12 @@ internal class CreateProblemViewModel @Inject constructor( questionIndex: Int, correctAnswer: String, ) = intent { - val newCorrectAnswers = state.createProblem.correctAnswers.toMutableList() + val newCorrectAnswers = state.createExam.correctAnswers.toMutableList() newCorrectAnswers[questionIndex] = correctAnswer reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( correctAnswers = newCorrectAnswers.toPersistentList(), ), ) @@ -772,7 +772,7 @@ internal class CreateProblemViewModel @Inject constructor( questionIndex: Int, answerType: Answer.Type, ) = intent { - val newAnswers = state.createProblem.answers.toMutableList() + val newAnswers = state.createExam.answers.toMutableList() val newAnswer = newAnswers[questionIndex] when (answerType) { @@ -796,8 +796,8 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( - questions = state.createProblem.questions, + createExam = state.createExam.copy( + questions = state.createExam.questions, answers = newAnswers.toPersistentList(), ), ) @@ -805,8 +805,8 @@ internal class CreateProblemViewModel @Inject constructor( } /** 문제 만들기 2단계 화면의 유효성을 체크한다. */ - internal fun createProblemIsValidate(): Boolean { - return with(container.stateFlow.value.createProblem) { + internal fun createExamIsValidate(): Boolean { + return with(container.stateFlow.value.createExam) { val examCountValidate = this.questions.size in MinimumProblem..MaximumProblem val questionsValidate = this.questions.asSequence() .map { it.validate() } @@ -878,14 +878,14 @@ internal class CreateProblemViewModel @Inject constructor( /** 문제 만들기 전체 화면의 유효성을 체크한다. */ internal fun isAllFieldsNotEmpty(): Boolean { - return examInformationIsValidate() && createProblemIsValidate() && additionInfoIsValidate() + return examInformationIsValidate() && createExamIsValidate() && additionInfoIsValidate() } /** 태그 항목들을 등록하기 위한 검색화면으로 진입한다. */ internal fun goToSearchSubTags() = intent { reduce { state.copy( - createProblemStep = CreateProblemStep.Search, + createExamStep = CreateProblemStep.Search, findResultType = FindResultType.SubTags, ) } @@ -970,7 +970,7 @@ internal class CreateProblemViewModel @Inject constructor( when (state.findResultType) { FindResultType.MainTag -> { state.copy( - createProblemStep = CreateProblemStep.ExamInformation, + createExamStep = CreateProblemStep.ExamInformation, examInformation = state.examInformation.run { copy( isMainTagSelected = true, @@ -1019,7 +1019,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { when (state.findResultType) { FindResultType.MainTag -> state.copy( - createProblemStep = CreateProblemStep.ExamInformation, + createExamStep = CreateProblemStep.ExamInformation, examInformation = state.examInformation.run { copy( isMainTagSelected = false, @@ -1032,7 +1032,7 @@ internal class CreateProblemViewModel @Inject constructor( ) FindResultType.SubTags -> state.copy( - createProblemStep = CreateProblemStep.AdditionalInformation, + createExamStep = CreateProblemStep.AdditionalInformation, additionalInfo = state.additionalInfo.run { copy( isSubTagsAdded = isComplete, diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/sideeffect/CreateProblemSideEffect.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/sideeffect/CreateProblemSideEffect.kt similarity index 84% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/sideeffect/CreateProblemSideEffect.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/sideeffect/CreateProblemSideEffect.kt index 5486b0ac4..811d4dce0 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/sideeffect/CreateProblemSideEffect.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/sideeffect/CreateProblemSideEffect.kt @@ -5,12 +5,12 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.viewmodel.sideeffect +package team.duckie.app.android.feature.create.exam.viewmodel.sideeffect import com.google.firebase.crashlytics.FirebaseCrashlytics import team.duckie.app.android.domain.gallery.usecase.LoadGalleryImagesUseCase -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemState +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemState internal sealed class CreateProblemSideEffect { object FinishActivity : CreateProblemSideEffect() diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemState.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemState.kt similarity index 95% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemState.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemState.kt index 21599268e..56768593c 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemState.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemState.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.viewmodel.state +package team.duckie.app.android.feature.create.exam.viewmodel.state import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -23,9 +23,9 @@ internal data class CreateProblemState( val me: User? = null, val isNetworkError: Boolean = false, val isEditMode: Boolean = false, - val createProblemStep: CreateProblemStep = CreateProblemStep.Loading, + val createExamStep: CreateProblemStep = CreateProblemStep.Loading, val examInformation: ExamInformation = ExamInformation(), - val createProblem: CreateProblem = CreateProblem(), + val createExam: CreateProblem = CreateProblem(), val additionalInfo: AdditionInfo = AdditionInfo(), val findResultType: FindResultType = FindResultType.MainTag, val photoState: CreateProblemPhotoState? = null, diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemStep.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemStep.kt similarity index 87% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemStep.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemStep.kt index bb568dda2..3ff0d672d 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemStep.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemStep.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.viewmodel.state +package team.duckie.app.android.feature.create.exam.viewmodel.state import team.duckie.app.android.common.kotlin.AllowMagicNumber @@ -13,7 +13,7 @@ import team.duckie.app.android.common.kotlin.AllowMagicNumber enum class CreateProblemStep(private val index: Int) { Loading(0), ExamInformation(1), - CreateProblem(2), + CreateExam(2), AdditionalInformation(3), Search(4), Error(5), diff --git a/feature/create-problem/src/main/res/values/strings.xml b/feature/create-exam/src/main/res/values/strings.xml similarity index 100% rename from feature/create-problem/src/main/res/values/strings.xml rename to feature/create-exam/src/main/res/values/strings.xml diff --git a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/BottomContent.kt b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/BottomContent.kt index 05748e915..9c6f813a9 100644 --- a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/BottomContent.kt +++ b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/BottomContent.kt @@ -29,7 +29,7 @@ import team.duckie.quackquack.material.icon.quackicon.outlined.Heart import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackButton import team.duckie.quackquack.ui.QuackButtonStyle -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi /** 상세 화면 최하단 Layout */ @@ -54,11 +54,11 @@ internal fun DetailBottomLayout( horizontalArrangement = Arrangement.SpaceBetween, ) { // 좋아요 버튼 - QuackImage( + QuackIcon( modifier = Modifier .size(DpSize(24.dp, 24.dp)) .quackClickable(onClick = onHeartClick), - src = if (state.isHeart) { + icon = if (state.isHeart) { QuackIcon.FilledHeart } else { QuackIcon.Outlined.Heart diff --git a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/DetailContent.kt b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/DetailContent.kt index a67c8147b..dbb6eac53 100644 --- a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/DetailContent.kt +++ b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/DetailContent.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.aspectRatio 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.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -46,7 +45,7 @@ import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.More import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackTag import team.duckie.quackquack.ui.QuackTagStyle import team.duckie.quackquack.ui.QuackText @@ -101,14 +100,14 @@ internal fun DetailContentLayout( ) // 더보기 아이콘 - QuackImage( + QuackIcon( modifier = Modifier .width(24.dp) .height(24.dp) .quackClickable( onClick = moreButtonClick, ), - src = QuackIcon.Outlined.More, + icon = QuackIcon.Outlined.More, ) } @@ -173,11 +172,12 @@ private fun DetailProfileLayout( profileClick: (Int) -> Unit, ) { val isFollowed = remember(state.isFollowing) { state.isFollowing } + val onProfileClick = { profileClick(state.exam.user?.id ?: 0) } Row( modifier = Modifier .quackClickable( - onClick = { profileClick(state.exam.user?.id ?: 0) }, + onClick = onProfileClick, rippleEnabled = false, ) .padding( @@ -190,6 +190,7 @@ private fun DetailProfileLayout( QuackProfileImage( profileUrl = state.profileImageUrl, size = DpSize(36.dp, 36.dp), + onClick = onProfileClick, ) // 공백 diff --git a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/TopCustomBar.kt b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/TopCustomBar.kt index 2b622901f..494ec85cd 100644 --- a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/TopCustomBar.kt +++ b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/TopCustomBar.kt @@ -29,7 +29,7 @@ import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowRight import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackTag import team.duckie.quackquack.ui.QuackTagStyle import team.duckie.quackquack.ui.trailingIcon @@ -54,14 +54,14 @@ internal fun TopAppCustomBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - QuackImage( + QuackIcon( modifier = Modifier .padding(6.dp) .size(DpSize(24.dp, 24.dp)) .quackClickable( onClick = { activity.finish() }, ), - src = QuackIcon.Outlined.ArrowBack, + icon = QuackIcon.Outlined.ArrowBack, ) QuackTag( diff --git a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/screen/quiz/QuizDetailScreen.kt b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/screen/quiz/QuizDetailScreen.kt index 519e7e6d2..f5736dab4 100644 --- a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/screen/quiz/QuizDetailScreen.kt +++ b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/screen/quiz/QuizDetailScreen.kt @@ -262,6 +262,7 @@ private fun RankingContent( } } } + Divider(color = QuackColor.Gray4.value) } } diff --git a/feature/exam-result/build.gradle.kts b/feature/exam-result/build.gradle.kts index 24520d71e..59de6901e 100644 --- a/feature/exam-result/build.gradle.kts +++ b/feature/exam-result/build.gradle.kts @@ -32,8 +32,8 @@ dependencies { libs.compose.ui.coil, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold - libs.quack.ui.components, libs.quack.v2.ui, + libs.kotlin.collections.immutable, libs.firebase.crashlytics, ) } diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/ExamResultActivity.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/ExamResultActivity.kt index a8bdffcdc..603663392 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/ExamResultActivity.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/ExamResultActivity.kt @@ -23,7 +23,7 @@ import team.duckie.app.android.feature.exam.result.screen.ExamResultScreen import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultSideEffect import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultViewModel import team.duckie.app.android.navigator.feature.startexam.StartExamNavigator -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint @@ -78,6 +78,7 @@ class ExamResultActivity : BaseActivity() { withFinish = true, ) } + is ExamResultSideEffect.SendReactionSuccessToast -> { ToastWrapper(this).invoke(getString(R.string.exam_result_post_reaction_success)) } diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/LoadingIndicator.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/LoadingIndicator.kt deleted file mode 100644 index 5e6bb06a3..000000000 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/LoadingIndicator.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Designed and developed by Duckie Team, 2022 - * - * Licensed under the MIT. - * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE - */ - -package team.duckie.app.android.feature.exam.result.common - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import team.duckie.quackquack.ui.color.QuackColor - -// TODO(EvergreenTree97): QuackLoadingIndicator로 통합 필요 -@Composable -internal fun LoadingIndicator() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = QuackColor.DuckieOrange.composeColor) - } -} diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/ResultBottomBar.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/ResultBottomBar.kt index 9e10a4535..de40e41fe 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/ResultBottomBar.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/ResultBottomBar.kt @@ -5,8 +5,11 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.exam.result.common +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -18,13 +21,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSurface +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.feature.exam.result.R -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSmallButton -import team.duckie.quackquack.ui.component.QuackSmallButtonType -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackSurface +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun ResultBottomBar( @@ -51,13 +53,9 @@ internal fun ResultBottomBar( onClick = onClickRetryButton, ) } - QuackSmallButton( - modifier = Modifier - .heightIn(44.dp) - .weight(1f), - type = QuackSmallButtonType.Fill, + TempFlexiblePrimaryLargeButton( + modifier = Modifier.weight(1f), text = stringResource(id = R.string.exam_result_exit_exam), - enabled = true, onClick = onClickExitButton, ) } @@ -72,7 +70,7 @@ private fun GrayBorderSmallButton( QuackSurface( modifier = modifier, backgroundColor = QuackColor.White, - border = QuackBorder(color = QuackColor.Gray3), + border = BorderStroke(width = 1.dp, color = QuackColor.Gray3.value), shape = RoundedCornerShape(size = 8.dp), onClick = onClick, ) { diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultScreen.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultScreen.kt index b794d44c0..2c324395a 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultScreen.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -47,6 +47,7 @@ import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.dialog.DuckieBottomSheetDialog import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog import team.duckie.app.android.common.compose.ui.quack.todo.QuackReactionTextArea +import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar import team.duckie.app.android.common.compose.util.HandleKeyboardVisibilityWithSheet import team.duckie.app.android.feature.exam.result.R import team.duckie.app.android.feature.exam.result.common.ResultBottomBar @@ -57,8 +58,9 @@ import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultScreen import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultState import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultViewModel import team.duckie.quackquack.material.QuackColor -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.icon.quackicon.outlined.Share import team.duckie.quackquack.ui.span import team.duckie.quackquack.ui.sugar.QuackHeadLine1 @@ -231,9 +233,9 @@ private fun ExamResultSuccessScreen( modifier = Modifier .padding(vertical = 8.dp) .padding(horizontal = 16.dp), - leadingIcon = QuackIcon.Close, + leadingIcon = team.duckie.quackquack.material.icon.QuackIcon.Outlined.Close, onLeadingIconClick = viewModel::exitExam, - trailingIcon = QuackIcon.Share, + trailingIcon = team.duckie.quackquack.material.icon.QuackIcon.Outlined.Share, onTrailingIconClick = { viewModel.updateExamResultScreen(ExamResultScreen.SHARE_EXAM_RESULT) }, @@ -252,11 +254,12 @@ private fun ExamResultSuccessScreen( PullRefreshIndicator( modifier = Modifier .fillMaxWidth() - .wrapContentWidth(CenterHorizontally) + .wrapContentWidth(Alignment.CenterHorizontally) .zIndex(1f), refreshing = state.isRefreshing, state = pullRefreshState, ) + Column( modifier = Modifier .fillMaxSize() @@ -282,9 +285,6 @@ private fun ExamResultSuccessScreen( nickname = nickname, myAnswer = myAnswer, profileImg = profileImg, - onHeartComment = { isLike -> - viewModel.heartWrongComment(isLike) - }, initialState = { viewModel.initialQuizState() }, diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultShareScreen.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultShareScreen.kt index 9190766cc..4625f27f0 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultShareScreen.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultShareScreen.kt @@ -43,22 +43,22 @@ import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import kotlinx.coroutines.launch import team.duckie.app.android.common.android.image.saveImageInGallery -import team.duckie.app.android.common.compose.util.ComposeToBitmap import team.duckie.app.android.common.compose.GetHeightRatioW328H240 import team.duckie.app.android.common.compose.rememberToast import team.duckie.app.android.common.compose.ui.BackPressedTopAppBar +import team.duckie.app.android.common.compose.ui.QuackDivider import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.icon.v2.Download import team.duckie.app.android.common.compose.ui.icon.v2.DuckieTextLogo +import team.duckie.app.android.common.compose.util.ComposeToBitmap import team.duckie.app.android.common.kotlin.toHourMinuteSecond import team.duckie.app.android.feature.exam.result.R import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultState import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.component.QuackDivider import team.duckie.quackquack.ui.icons import team.duckie.quackquack.ui.span import team.duckie.quackquack.ui.sugar.QuackBody1 @@ -251,9 +251,9 @@ private fun ExamResultImage( Spacer(space = 4.dp) QuackBody1(text = "${state.solvedCount}명 중 ${round(state.percent)}%!") Spacer(space = 24.dp) - QuackImage( + QuackIcon( modifier = Modifier.size(48.dp, 16.dp), - src = QuackIcon.DuckieTextLogo, + icon = QuackIcon.DuckieTextLogo, ) } } diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/quiz/QuizResultContent.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/quiz/QuizResultContent.kt index 04147218c..b63c3e0b6 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/quiz/QuizResultContent.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/quiz/QuizResultContent.kt @@ -10,7 +10,6 @@ package team.duckie.app.android.feature.exam.result.screen.quiz import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -51,6 +50,7 @@ import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.span import team.duckie.quackquack.ui.sugar.QuackHeadLine1 import team.duckie.quackquack.ui.sugar.QuackHeadLine2 +import team.duckie.quackquack.ui.sugar.QuackQuote import java.util.Locale @Composable @@ -70,7 +70,6 @@ internal fun QuizResultContent( isPerfectChallenge: Boolean, profileImg: String, myAnswer: String, - onHeartComment: (Int) -> Unit, comments: ImmutableList, commentsTotal: Int, showCommentSheet: () -> Unit, @@ -136,22 +135,7 @@ internal fun QuizResultContent( Spacer(space = 16.dp) // TODO(limsaehyun): QuackText Quote의 버그가 픽스된 후 아래 코드로 변경해야 함 // https://duckie-team.slack.com/archives/C054HU0CKMY/p1688278156256779 - // QuackText(text = message, typography = QuackTypography.Quote) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 30.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - QuackHeadLine1(text = "\"") - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center, - ) { - QuackHeadLine1(text = message) - } - QuackHeadLine1(text = "\"") - } + QuackQuote(text = message) Spacer(space = 24.dp) QuackMaxWidthDivider() Row( @@ -223,7 +207,6 @@ internal fun QuizResultContent( ChallengeCommentSection( profileUrl = profileImg, myAnswer = myAnswer, - onHeartComment = onHeartComment, comments = comments, commentsTotal = commentsTotal, showCommentSheet = showCommentSheet, diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeComment.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeComment.kt index 68291f0c9..576436ff2 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeComment.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeComment.kt @@ -43,9 +43,9 @@ import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.Flag import team.duckie.quackquack.material.icon.quackicon.outlined.Heart +import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.modifier.quackClickable import team.duckie.quackquack.ui.sugar.QuackBody1 @Composable @@ -118,12 +118,12 @@ internal fun ChallengeComment( modifier: Modifier = Modifier, wrongComment: ExamResultState.Success.ChallengeCommentUiModel, innerPaddingValues: PaddingValues = PaddingValues(), - onHeartClick: (Int) -> Unit, + onHeartClick: ((Int) -> Unit)?, visibleHeart: Boolean, showCommentSheet: () -> Unit, ) { val animateHeartColor = - animateQuackColorAsState(targetValue = if (wrongComment.isHeart) QuackColor.Gray1 else QuackColor.Gray2) + animateQuackColorAsState(targetValue = if (wrongComment.isHeart)QuackColor.Gray1 else QuackColor.Gray2) Row( modifier = modifier @@ -131,7 +131,7 @@ internal fun ChallengeComment( .background(QuackColor.White.value) .padding(innerPaddingValues) .quackClickable( - rippleEnabled = true, + rippleEnabled = false, onClick = showCommentSheet, ) .padding(vertical = 8.dp), @@ -175,7 +175,9 @@ internal fun ChallengeComment( .size(24.dp) .quackClickable( onClick = { - onHeartClick(wrongComment.id) + if (onHeartClick != null) { + onHeartClick(wrongComment.id) + } }, ), icon = if (wrongComment.isHeart) { diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentBottomSheetContent.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentBottomSheetContent.kt index 6de752df0..550d2b898 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentBottomSheetContent.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentBottomSheetContent.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import team.duckie.app.android.common.compose.WrapScaffoldLayout +import team.duckie.app.android.common.compose.ui.QuackDivider import team.duckie.app.android.common.compose.ui.QuackIconWrapper import team.duckie.app.android.common.compose.ui.icon.v1.ArrowSendId import team.duckie.app.android.common.compose.ui.icon.v2.Order18 @@ -39,7 +40,6 @@ import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.component.QuackDivider @Composable internal fun ChallengeCommentBottomSheetContent( diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentSection.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentSection.kt index ffd7810ab..0217ffaf3 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentSection.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentSection.kt @@ -57,7 +57,6 @@ private val PaleOrange: Color = Color(0xFFFFEFCF) internal fun ColumnScope.ChallengeCommentSection( profileUrl: String, myAnswer: String, - onHeartComment: (Int) -> Unit, commentsTotal: Int, comments: ImmutableList, showCommentSheet: () -> Unit, @@ -155,10 +154,12 @@ internal fun ColumnScope.ChallengeCommentSection( ChallengeComment( modifier = Modifier.fillMaxScreenWidth(), wrongComment = item, - onHeartClick = onHeartComment, innerPaddingValues = PaddingValues(horizontal = 16.dp), visibleHeart = true, showCommentSheet = showCommentSheet, + onHeartClick = { + showCommentSheet() // 하트 클릭 미지원 + }, ) Spacer(space = 8.dp) } diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/viewmodel/ExamResultViewModel.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/viewmodel/ExamResultViewModel.kt index 37e9e22fd..323459441 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/viewmodel/ExamResultViewModel.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/viewmodel/ExamResultViewModel.kt @@ -158,6 +158,7 @@ class ExamResultViewModel @Inject constructor( updateRefreshState(true) getChallengeCommentList() state.updateCommentCreateAt() + refreshQuiz() if (forceLoading) delay(pullToRefreshMinLoadingDelay) updateRefreshState(false) } @@ -183,10 +184,19 @@ class ExamResultViewModel @Inject constructor( } } + private var isWriteSending: Boolean = false + private val existSendingMessage: String = "열심히 댓글을 등록하고 있어요! 조금만 기다려 주세요." + fun writeChallengeComment() = intent { val state = state as ExamResultState.Success val myComment = state.myWrongComment + if (isWriteSending) { + postSideEffect(ExamResultSideEffect.SendErrorToast(existSendingMessage)) + return@intent + } + isWriteSending = true + if (myComment.isNotEmpty()) { writeChallengeCommentUseCase( challengeId = state.examId, @@ -205,6 +215,8 @@ class ExamResultViewModel @Inject constructor( } }.onFailure { exception -> postSideEffect(ExamResultSideEffect.ReportError(exception)) + }.also { + isWriteSending = false } } } @@ -459,6 +471,28 @@ class ExamResultViewModel @Inject constructor( } } + private fun refreshQuiz() = intent { + val state = state as ExamResultState.Success + getQuizUseCase(state.examId).onSuccess { quizResult -> + reduce { + with(quizResult) { + state.copy( + popularComments = popularComments?.fastMap(ChallengeComment::toUiModel) + ?.toImmutableList() ?: persistentListOf(), + commentsTotal = commentsTotal ?: 0, + equalAnswerCount = wrongAnswer?.meTotal ?: 0, + ) + } + } + }.onFailure { + it.printStackTrace() + reduce { + ExamResultState.Error(exception = it) + } + postSideEffect(ExamResultSideEffect.ReportError(it)) + } + } + fun updateReaction(reaction: String) = intent { val state = state as ExamResultState.Success diff --git a/feature/friends/build.gradle.kts b/feature/friends/build.gradle.kts index 60c50cfbd..9146e1e10 100644 --- a/feature/friends/build.gradle.kts +++ b/feature/friends/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { libs.ktx.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold libs.compose.lifecycle.runtime, - libs.quack.ui.components, + libs.quack.v2.ui, + libs.kotlin.collections.immutable, ) } diff --git a/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsActivity.kt b/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsActivity.kt index 2b379a561..ebce07de5 100644 --- a/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsActivity.kt +++ b/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsActivity.kt @@ -14,14 +14,14 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import dagger.hilt.android.AndroidEntryPoint import org.orbitmvi.orbit.viewmodel.observe -import team.duckie.app.android.feature.friends.viewmodel.FriendsViewModel -import team.duckie.app.android.feature.friends.viewmodel.sideeffect.FriendsSideEffect -import team.duckie.app.android.navigator.feature.profile.ProfileNavigator import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.common.android.ui.const.Extras import team.duckie.app.android.common.android.ui.finishWithAnimation -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.app.android.feature.friends.viewmodel.FriendsViewModel +import team.duckie.app.android.feature.friends.viewmodel.sideeffect.FriendsSideEffect +import team.duckie.app.android.navigator.feature.profile.ProfileNavigator +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint diff --git a/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsScreen.kt b/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsScreen.kt index 4481cd03b..f2de31a07 100644 --- a/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsScreen.kt +++ b/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsScreen.kt @@ -13,7 +13,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager @@ -23,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -32,19 +35,21 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch -import team.duckie.app.android.feature.friends.viewmodel.FriendsViewModel -import team.duckie.app.android.feature.friends.viewmodel.state.FriendsState +import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.compose.ui.BackPressedHeadLine2TopAppBar import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.NoItemScreen import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.compose.ui.UserFollowingLayout +import team.duckie.app.android.common.compose.ui.content.UserFollowingLayout import team.duckie.app.android.common.compose.ui.skeleton -import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.kotlin.FriendsType -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackMainTab +import team.duckie.app.android.feature.friends.viewmodel.FriendsViewModel +import team.duckie.app.android.feature.friends.viewmodel.state.FriendsState +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackTab +import team.duckie.quackquack.ui.QuackTabColors +import team.duckie.quackquack.ui.QuackText @Composable internal fun FriendScreen( @@ -59,31 +64,36 @@ internal fun FriendScreen( ) } val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() - val pagerState = rememberPagerState(initialPage = state.friendType.index) + val pagerState = rememberPagerState( + initialPage = state.friendType.index, + pageCount = { FriendsType.values().size }, + ) val coroutineScope = rememberCoroutineScope() Column( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .padding(systemBarPaddings), ) { BackPressedHeadLine2TopAppBar( title = state.targetName, onBackPressed = onPrevious, ) - QuackMainTab( - titles = tabs, - selectedTabIndex = pagerState.currentPage, - onTabSelected = { index -> - coroutineScope.launch { - pagerState.animateScrollToPage(index) + QuackTab( + index = pagerState.currentPage, + colors = QuackTabColors.defaultTabColors(), + ) { + tabs.forEach { label -> + tab(label) { index -> + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } } - }, - ) + } + } HorizontalPager( modifier = Modifier.fillMaxSize(), - pageCount = FriendsType.values().size, state = pagerState, ) { index -> if (state.isError) { @@ -198,7 +208,9 @@ private fun FriendNotFoundScreen( ) } else { NoItemScreen( - modifier = Modifier.padding(top = 72.dp), + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(CenterHorizontally), title = stringResource( id = if (isFollower) { R.string.follower_not_found_title @@ -228,11 +240,13 @@ private fun MyPageFriendNotFoundScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(space = 74.5.dp) - QuackHeadLine2( + QuackText( modifier = Modifier.skeleton(isLoading), text = title, - color = QuackColor.Gray1, - align = TextAlign.Center, + typography = QuackTypography.HeadLine2.change( + color = QuackColor.Gray1, + textAlign = TextAlign.Center, + ), ) } } @@ -257,10 +271,10 @@ private fun FriendListScreen( favoriteTag = item.favoriteTag, tier = item.tier, isFollowing = item.isFollowing, - onClickFollow = { follow -> + onClickTrailingButton = { follow -> onClickFollow(item.userId, follow) }, - isMine = myUserId == item.userId, + visibleTrailingButton = myUserId != item.userId, onClickUserProfile = onClickUserProfile, ) } diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index f2a35bd98..60b93a5fd 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -32,7 +32,7 @@ dependencies { projects.navigator, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, + libs.kotlin.collections.immutable, libs.quack.v2.ui, libs.compose.lifecycle.runtime, libs.compose.ui.navigation, diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/BaseBottomLayout.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/BaseBottomLayout.kt index 257eb72ae..3aa960314 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/BaseBottomLayout.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/BaseBottomLayout.kt @@ -20,8 +20,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackDivider +import team.duckie.app.android.common.compose.ui.DuckieDivider +import team.duckie.quackquack.material.QuackColor @Composable internal fun BaseBottomLayout( @@ -36,11 +36,11 @@ internal fun BaseBottomLayout( .fillMaxWidth() .height(48.dp), ) { - QuackDivider() + DuckieDivider() Row( modifier = Modifier .fillMaxSize() - .background(QuackColor.White.composeColor) + .background(QuackColor.White.value) .padding(contentPadding), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/DuckTestBottomNavigation.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/DuckTestBottomNavigation.kt index 3d3faceb6..d5e164824 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/DuckTestBottomNavigation.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/DuckTestBottomNavigation.kt @@ -18,21 +18,23 @@ 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.size import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import okhttp3.internal.immutableListOf -import team.duckie.app.android.feature.home.R import team.duckie.app.android.common.kotlin.fastForEachIndexed -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.app.android.feature.home.R +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText /** * [DuckTestBottomNavigation] 를 그리는데 필요한 리소스들을 정의합니다. @@ -41,7 +43,7 @@ private object DuckTestBottomNavigationDefaults { val Height = 52.dp val BackgroundColor = QuackColor.White - val IconSize = DpSize(all = 24.dp) + val IconSize = DpSize(width = 24.dp, height = 24.dp) } /** @@ -71,7 +73,7 @@ internal fun DuckTestBottomNavigation( height = Height, ) .background( - color = BackgroundColor.composeColor, + color = BackgroundColor.value, ), ) { rememberBottomNavigationIcons().fastForEachIndexed { index, icons -> @@ -83,25 +85,29 @@ internal fun DuckTestBottomNavigation( .fillMaxSize() .quackClickable( rippleEnabled = false, - ) { - onClick( - /* index = */ - index, - ) - }, + onClick = { + onClick(index) + }, + ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { QuackImage( + modifier = Modifier.size(IconSize), src = icons.pick( isSelected = index == selectedIndex, ), - size = IconSize, ) Spacer(modifier = Modifier.height(8.dp)) - QuackBody2( + QuackText( text = stringResource(id = icons.title), - color = if (index == selectedIndex) QuackColor.Black else QuackColor.Gray1, + typography = QuackTypography.Body2.change( + color = if (index == selectedIndex) { + QuackColor.Black + } else { + QuackColor.Gray1 + }, + ), ) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HeadLineTopAppBar.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HeadLineTopAppBar.kt index 75e752aae..bd890976f 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HeadLineTopAppBar.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HeadLineTopAppBar.kt @@ -15,7 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.component.QuackHeadLine1 +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 @Composable internal fun HeadLineTopAppBar( diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HomeTopAppBar.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HomeTopAppBar.kt index 9ba9647dd..fb6274483 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HomeTopAppBar.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HomeTopAppBar.kt @@ -22,24 +22,23 @@ import okhttp3.internal.immutableListOf import team.duckie.app.android.common.compose.ui.TextTabLayout import team.duckie.app.android.feature.home.R import team.duckie.quackquack.material.QuackTypography -import team.duckie.quackquack.ui.util.DpSize import team.duckie.quackquack.material.QuackColor as QuackV2Color -internal val HomeIconSize = DpSize(24.dp) - -@Suppress("UnusedPrivateMember") // 시험 생성하기를 추후에 다시 활용하기 위함 +@Suppress("unused") // 진행중 API 추가 작업을 위함 @Composable internal fun HomeTopAppBar( modifier: Modifier = Modifier, selectedTabIndex: Int, onTabSelected: (Int) -> Unit, onClickedCreate: () -> Unit, + onClickedNotice: () -> Unit, ) { val context = LocalContext.current val homeTextTabTitles = remember { immutableListOf( context.getString(R.string.recommend), + // context.getString(R.string.proceed), context.getString(R.string.following), ) } @@ -53,15 +52,25 @@ internal fun HomeTopAppBar( ) { TextTabLayout( titles = homeTextTabTitles.toImmutableList(), + selectedTabStyle = QuackTypography.HeadLine1, selectedTabIndex = selectedTabIndex, onTabSelected = onTabSelected, - tabStyle = QuackTypography.Title2.change(color = QuackV2Color.Gray2), + tabStyle = QuackTypography.HeadLine1.change(color = QuackV2Color.Gray2), ) -// TODO(limsaehyun): 시험 생성하기가 가능한 스펙에서 활용 -// QuackImage( -// src = QuackIcon.Create, -// onClick = onClickedCreate, -// size = HomeIconSize, -// ) + + // QuackImage( + // src = R.drawable.home_ic_notice, + // modifier = Modifier + // .quackClickable(onClick = onClickedNotice) + // .size(DpSize(24.dp, 24.dp)), + // ) + // + // // TODO(riflockle7): 임시로 활성화 + // QuackIcon( + // modifier = Modifier + // .quackClickable(onClick = onClickedCreate) + // .size(DpSize(24.dp, 24.dp)), + // icon = OutlinedGroup.Create, + // ) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/constants/HomeStep.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/constants/HomeStep.kt index e4dc92a1b..1200d7d42 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/constants/HomeStep.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/constants/HomeStep.kt @@ -25,6 +25,11 @@ internal enum class HomeStep( HomeFollowingScreen( index = 1, ), + + // TODO(riflockle7): 추후 index = 1 + HomeProceedScreen( + index = 2, + ), ; companion object { diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainActivity.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainActivity.kt index 59bab7cce..01243bca0 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainActivity.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainActivity.kt @@ -43,7 +43,7 @@ import team.duckie.app.android.navigator.feature.profile.ViewAllNavigator import team.duckie.app.android.navigator.feature.search.SearchNavigator import team.duckie.app.android.navigator.feature.setting.SettingNavigator import team.duckie.app.android.navigator.feature.tagedit.TagEditNavigator -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AllowMagicNumber("앱 종료 시간에 대해서 매직 넘버 처리") @@ -57,7 +57,7 @@ class MainActivity : BaseActivity() { private val homeViewModel: HomeViewModel by viewModels() @Inject - lateinit var createProblemNavigator: CreateProblemNavigator + lateinit var createExamNavigator: CreateProblemNavigator @Inject lateinit var notificationNavigator: NotificationNavigator @@ -162,11 +162,6 @@ class MainActivity : BaseActivity() { } } - override fun onStart() { - super.onStart() - myPageViewModel.getUserProfile() - } - private fun startedGuide(intent: Intent) { intent.getBooleanExtra(Extras.StartGuide, false).also { start -> if (start) { @@ -186,6 +181,7 @@ class MainActivity : BaseActivity() { startActivityWithAnimation( intentBuilder = { putExtra(Extras.SearchTag, sideEffect.searchTag) + putExtra(Extras.AutoFocusing, sideEffect.autoFocusing) }, ) } @@ -200,7 +196,7 @@ class MainActivity : BaseActivity() { } is MainSideEffect.NavigateToCreateProblem -> { - createProblemNavigator.navigateFrom(activity = this) + createExamNavigator.navigateFrom(activity = this) } is MainSideEffect.NavigateToSetting -> { @@ -233,6 +229,15 @@ class MainActivity : BaseActivity() { is MainSideEffect.CopyExamIdDynamicLink -> { DynamicLinkHelper.createAndShareLink(this, sideEffect.examId) } + + is MainSideEffect.NavigateToProfile -> { + profileNavigator.navigateFrom( + activity = this, + intentBuilder = { + putExtra(Extras.UserId, sideEffect.userId) + }, + ) + } } } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainScreen.kt index e2b092dc5..f168c6a7b 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout @@ -37,8 +38,7 @@ import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel import team.duckie.app.android.feature.home.viewmodel.mypage.MyPageViewModel import team.duckie.app.android.feature.home.viewmodel.ranking.RankingViewModel import team.duckie.app.android.feature.profile.viewmodel.state.ExamType -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackDivider +import team.duckie.quackquack.material.QuackColor private const val MainCrossFacadeLayoutId = "MainCrossFacade" private const val MainBottomNavigationDividerLayoutId = "MainBottomNavigationDivider" @@ -114,7 +114,7 @@ internal fun MainScreen( Layout( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .padding(systemBarPaddings), content = { HomeGuideScreen( @@ -141,6 +141,7 @@ internal fun MainScreen( navigateToCreateProblem = { mainViewModel.navigateToCreateProblem() }, + navigateToProfile = mainViewModel::navigateToProfile, setTargetExamId = { examId -> mainViewModel.setTargetExamId(examId) }, @@ -209,7 +210,7 @@ internal fun MainScreen( } } - QuackDivider( + Divider( modifier = Modifier.layoutId(MainBottomNavigationDividerLayoutId), ) DuckTestBottomNavigation( diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideFeatureScrren.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideFeatureScrren.kt index fc4cad724..6bd5e8c98 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideFeatureScrren.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideFeatureScrren.kt @@ -28,15 +28,14 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.DuckieHorizontalPagerIndicator import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.component.BaseBottomLayout import team.duckie.app.android.feature.home.constants.GuideStep -import team.duckie.app.android.common.compose.ui.DuckieHorizontalPagerIndicator -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText @Composable internal fun HomeGuideFeatureScreen( @@ -46,17 +45,21 @@ internal fun HomeGuideFeatureScreen( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, content = { - QuackHeadLine2( + QuackText( modifier = Modifier .padding(top = 41.dp), text = stringResource(id = guideStep.subtitle), - color = QuackColor.White, - align = TextAlign.Center, + typography = QuackTypography.HeadLine2.change( + color = QuackColor.White, + textAlign = TextAlign.Center, + ), ) - QuackHeadLine1( + QuackText( text = stringResource(id = guideStep.title), - color = QuackColor.DuckieOrange, - align = TextAlign.Center, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.DuckieOrange, + textAlign = TextAlign.Center, + ), ) Image( modifier = Modifier @@ -84,15 +87,13 @@ internal fun HomeGuideFeatureBottomLayout( modifier = Modifier .fillMaxWidth() .height(48.dp) - .quackClickable { - onNext() - } - .background(color = QuackColor.DuckieOrange.composeColor), + .quackClickable(onClick = onNext) + .background(color = QuackColor.DuckieOrange.value), contentAlignment = Alignment.Center, ) { - QuackHeadLine2( + QuackText( text = stringResource(id = R.string.start_duckie), - color = QuackColor.White, + typography = QuackTypography.HeadLine2.change(color = QuackColor.White), ) } } else { @@ -105,10 +106,12 @@ internal fun HomeGuideFeatureBottomLayout( ) }, trailingContent = { - QuackSubtitle( + QuackText( + modifier = Modifier.quackClickable(onClick = onNext), text = stringResource(id = R.string.next), - color = QuackColor.DuckieOrange, - onClick = onNext, + typography = QuackTypography.Subtitle.change( + color = QuackColor.DuckieOrange, + ), ) }, ) diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideScreen.kt index 736b433ad..c16a63376 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideScreen.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(ExperimentalFoundationApi::class) +@file:OptIn(ExperimentalFoundationApi::class, ExperimentalQuackQuackApi::class) package team.duckie.app.android.feature.home.screen.guide @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,17 +35,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.constants.GuideStep import team.duckie.app.android.feature.home.viewmodel.guide.HomeGuideViewModel -import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackSmallButton -import team.duckie.quackquack.ui.component.QuackSmallButtonType -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun HomeGuideScreen( @@ -53,14 +54,17 @@ internal fun HomeGuideScreen( onClose: () -> Unit, ) { val state = vm.collectAsState().value - val pagerState = rememberPagerState() - val pageCount = GuideStep.values().size + val pageCount = remember { GuideStep.values().size } + val pagerState = rememberPagerState( + + pageCount = { pageCount }, + ) val coroutineScope = rememberCoroutineScope() Box( modifier = modifier .fillMaxSize() - .background(color = QuackColor.Black.composeColor.copy(alpha = 0.9F)), + .background(color = QuackColor.Black.value.copy(alpha = 0.9F)), contentAlignment = Alignment.BottomCenter, ) { if (state.isGuideStarted) { @@ -82,18 +86,17 @@ internal fun HomeGuideScreen( .navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - QuackBody2( + QuackText( modifier = Modifier + .quackClickable(onClick = onClose) .fillMaxWidth() .wrapContentWidth(Alignment.End) .padding(top = 40.dp, end = 16.dp), text = stringResource(id = R.string.skip), - color = QuackColor.Gray3, - onClick = onClose, + typography = QuackTypography.Body2.change(color = QuackColor.Gray3), ) HorizontalPager( modifier = Modifier.fillMaxSize(), - pageCount = pageCount, state = pagerState, ) { index -> HomeGuideFeatureScreen( @@ -136,25 +139,26 @@ private fun HomeGuideStartScreen( contentScale = ContentScale.Fit, ) Spacer(space = 12.dp) - QuackHeadLine1( + QuackText( + modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(id = R.string.guide_start_message), - color = QuackColor.White, - align = TextAlign.Center, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.White, + textAlign = TextAlign.Center, + ), ) Spacer(space = 20.dp) - QuackSmallButton( - modifier = Modifier - .size(118.dp, 44.dp), - type = QuackSmallButtonType.Fill, + TempFlexiblePrimaryLargeButton( text = stringResource(id = R.string.guide_start_accept_message), + modifier = Modifier.size(118.dp, 44.dp), enabled = true, onClick = onNext, ) Spacer(space = 16.dp) - QuackBody2( + QuackText( + modifier = Modifier.quackClickable(onClick = onClosed), text = stringResource(id = R.string.guide_start_deny_message), - color = QuackColor.Gray2, - onClick = onClosed, + typography = QuackTypography.Body2.change(color = QuackColor.Gray2), ) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeProceedScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeProceedScreen.kt new file mode 100644 index 000000000..28038ede3 --- /dev/null +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeProceedScreen.kt @@ -0,0 +1,526 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn( + ExperimentalMaterialApi::class, + ExperimentalQuackQuackApi::class, +) + +@file:Suppress("unused") // 더미 값, 미구현 된 내용 +@file:AllowMagicNumber("더미 값, 미구현 된 내용") + +package team.duckie.app.android.feature.home.screen.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +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.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import team.duckie.app.android.common.compose.GetHeightRatioW129H84 +import team.duckie.app.android.common.compose.GetHeightRatioW328H240 +import team.duckie.app.android.common.compose.GetHeightRatioW360H90 +import team.duckie.app.android.common.compose.GetHeightRatioW85H63 +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.icon.v1.ArrowRightId +import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage +import team.duckie.app.android.common.kotlin.AllowMagicNumber +import team.duckie.app.android.feature.home.R +import team.duckie.app.android.feature.home.component.HomeTopAppBar +import team.duckie.app.android.feature.home.constants.HomeStep +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedCategory.categories +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedCategory.categoryThumbnailUrl +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedCategory.items +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.currentExamCount +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.joinCount +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.maximumExamCount +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.nickname +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.profileImageUrl +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.remainCount +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.thumbnailUrl +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.title +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.username +import team.duckie.app.android.feature.home.viewmodel.home.HomeState +import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackBody1 +import team.duckie.quackquack.ui.sugar.QuackSubtitle2 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +object HomeProceedTempConstants { + const val username = "무지개양말" + object ProceedItemView { + const val title = "투바투 덕력고사" + const val thumbnailUrl = + "https://duckie-resource.s3.ap-northeast-2.amazonaws.com/exam/thumbnail/1684545439537" + const val maximumExamCount = 10 + const val currentExamCount = 9 + val remainCount: Int + get() = maximumExamCount - currentExamCount + const val profileImageUrl = + "https://duckie-resource.s3.ap-northeast-2.amazonaws.com/profile/1686068260083" + const val nickname = "킹도로" + const val joinCount = 5 + } + + object ProceedCategory { + val categories = listOf("전체", "애니", "아이돌", "영화", "운동", "트렌드") + const val categoryThumbnailUrl = + "https://duckie-resource.s3.ap-northeast-2.amazonaws.com/exam/thumbnail/1684545439537" + val items = listOf("투바투 덕력고사", "베스킨라빈스 31 덕력고사", "예능 덕력고사", "코난 극장판 덕력고사", "아따맘마 덕력고사") + } +} + +@Suppress("unused") // 더미 값 +@Composable +internal fun HomeProceedScreen( + modifier: Modifier = Modifier, + state: HomeState, + homeViewModel: HomeViewModel = activityViewModel(), + navigateToCreateProblem: () -> Unit, + navigateToHomeDetail: (Int) -> Unit, + navigateToSearch: (String) -> Unit, + openExamBottomSheet: (Int) -> Unit, +) { + val pullRefreshState = rememberPullRefreshState( + refreshing = state.isHomeProceedPullRefreshLoading, + onRefresh = { + homeViewModel.refreshProceeds(forceLoading = true) + }, + ) + + Box(modifier = Modifier.pullRefresh(pullRefreshState)) { + LazyColumn(modifier = modifier.fillMaxSize()) { + item { + HomeTopAppBar( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + selectedTabIndex = state.homeSelectedIndex.index, + onTabSelected = { step -> + homeViewModel.changedHomeScreen(HomeStep.toStep(step)) + }, + onClickedCreate = navigateToCreateProblem, + onClickedNotice = {}, + ) + } + + // 공백 + item { + Spacer(Modifier.height(12.dp)) + } + + // 제목 + item { + QuackText( + modifier = Modifier.padding(start = 16.dp), + text = stringResource(id = R.string.home_proceed_title), + typography = QuackTypography.HeadLine1, + ) + } + + // 공백 + item { + Spacer(Modifier.height(12.dp)) + } + + // 진행중인 덕력고사 목록 뷰 + items(10) { + ProceedItemView() + } + + // 공백 + item { + Spacer(modifier = Modifier.height(8.dp)) + } + + // 전체보기 버튼 + item { + ProceedViewAllButton(onViewAllClick = {}) + } + + // 공백 + item { + Spacer(Modifier.height(48.dp)) + } + + // 덕력고사 진행중 배너 뷰 + item { + ProceedBannerView() + } + + // 공백 + item { + Spacer(Modifier.height(48.dp)) + } + + // 덕력고사 진행 중 카테고리 영역 뷰 + item { + ProceedCategorySection( + selectedTagIndex = 0, + tagItemClick = {}, + categories = categories, + items = items, + ) + } + } + PullRefreshIndicator( + modifier = Modifier.align(Alignment.TopCenter), + refreshing = state.isHomeRecommendPullRefreshLoading, + state = pullRefreshState, + ) + } +} + +/** 진행중인 덕력고사 Item 뷰 */ +@Composable +fun ProceedItemView() { + Column( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = QuackColor.Gray3.value, + shape = RoundedCornerShape(8.dp), + ) + .quackClickable( + onClick = {}, + ), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip( + RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp, + bottomEnd = 0.dp, + bottomStart = 0.dp, + ), + ), + ) { + // 덕퀴즈/덕질고사 썸네일 이미지 + AsyncImage( + model = thumbnailUrl, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(ratio = GetHeightRatioW328H240), + contentScale = ContentScale.FillBounds, + contentDescription = null, + ) + + // 오픈까지 x문제 + QuackText( + modifier = Modifier + .clip(RoundedCornerShape(bottomEnd = 4.dp)) + .background( + if (remainCount == 1) { + QuackColor.DuckieOrange.value + } else { + Color(0xFF222222) + }, + ) + .padding(vertical = 4.dp, horizontal = 8.dp), + text = stringResource( + id = R.string.home_proceed_item_count_down, + remainCount, + ), + typography = QuackTypography.Body3.change(color = QuackColor.White), + ) + } + + // 만들어진 문제 개수 / 최대 문제 개수 비율 막대 그래프 + Row(modifier = Modifier.height(8.dp)) { + // 첫 번째 막대 (8:2 비율) + Box( + modifier = Modifier + .fillMaxHeight() + .weight(currentExamCount.toFloat()) + .background(QuackColor.DuckieOrange.value), + ) + + // 두 번째 막대 (8:2 비율) + Box( + modifier = Modifier + .fillMaxHeight() + .weight(remainCount.toFloat()) + .background(QuackColor.Gray3.value), + ) + } + + // 덕력고사 정보 & 참여율 + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + // 덕력고사 이름 + QuackText(text = title, typography = QuackTypography.HeadLine2) + + Spacer(modifier = Modifier.height(4.dp)) + + Row(horizontalArrangement = Arrangement.Center) { + // 프로필 이미지 + QuackProfileImage( + modifier = Modifier.size(DpSize(width = 16.dp, height = 16.dp)), + profileUrl = profileImageUrl, + ) + + // 닉네임 + 참여 인원수 + QuackText( + modifier = Modifier.padding(start = 4.dp), + text = stringResource( + R.string.home_proceed_item_info, + nickname, + stringResource(id = R.string.home_proceed_item_join_count, joinCount), + ), + typography = QuackTypography.Body2.change(color = QuackColor.Gray1), + ) + + // 너비 + Spacer(weight = 1f) + + // 만들어진 문제 개수 / 최대 문제 개수 + QuackText( + modifier = Modifier.padding(start = 4.dp), + text = stringResource( + R.string.home_proceed_item_problem_count, + currentExamCount, + maximumExamCount, + ), + typography = QuackTypography.Body2.change(color = QuackColor.Gray1), + ) + } + } + } +} + +/** 배너 뷰 */ +@Composable +fun ProceedBannerView() { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(ratio = GetHeightRatioW360H90) + .background(Color(0xFFFFF8CF)), + ) { + Column( + modifier = Modifier + .padding(start = 16.dp) + .align(Alignment.CenterStart), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // 직접 덕력고사를 열고 싶다면 + QuackText( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(id = R.string.home_proceed_banner_title), + typography = QuackTypography.HeadLine2, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // 출제 제안하기 버튼 + Row( + modifier = Modifier + .clip(RoundedCornerShape(100.dp)) + .background(QuackColor.White.value) + .padding(vertical = 6.dp, horizontal = 12.dp), + ) { + // 텍스트 + QuackSubtitle2( + text = stringResource(id = R.string.home_proceed_banner_submit_button_title), + ) + + // 공백 + Spacer(space = 4.dp) + + // 우측 방향 화살표 + QuackImage( + modifier = Modifier.size(DpSize(width = 16.dp, height = 16.dp)), + src = QuackIcon.ArrowRightId, + ) + } + } + + // 배너 우측 이미지 + QuackImage( + modifier = Modifier + .padding(top = 6.dp) + .aspectRatio(GetHeightRatioW129H84) + .align(Alignment.CenterEnd), + src = R.drawable.home_proceed_banner_right, + ) + } +} + +/** 진행중인 덕력고사 카테고리 섹션 */ +@Composable +fun ProceedCategorySection( + selectedTagIndex: Int = 0, + tagItemClick: (String) -> Unit, + categories: List, + items: List, +) { + // 제목 + QuackText( + modifier = Modifier.padding(start = 16.dp), + text = stringResource( + id = R.string.home_proceed_category_title, + username, + ), + typography = QuackTypography.HeadLine1, + ) + + // 공백 + Spacer(modifier = Modifier.height(14.dp)) + + // 카테고리 목록 + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + itemsIndexed(items = categories) { index, tagName -> + QuackTag( + text = tagName, + style = QuackTagStyle.Outlined, + onClick = { + tagItemClick(tagName) + }, + selected = index == selectedTagIndex, + ) + } + } + + // 공백 + Spacer(modifier = Modifier.height(14.dp)) + + // 카테고리에 해당하는 덕력고사 목록 + Column { + items.forEach { item -> + ProceedCategoryItemView(categoryItem = item) + } + } + + // 전체보기 버튼 + ProceedViewAllButton(onViewAllClick = {}) + + // 공백 + Spacer(modifier = Modifier.height(20.dp)) +} + +/** 카테고리별 뷰[ProceedCategorySection]에 보이는 Item 뷰 */ +@Composable +fun ProceedCategoryItemView(categoryItem: String) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 덕퀴즈/덕질고사 썸네일 이미지 + AsyncImage( + model = categoryThumbnailUrl, + modifier = Modifier + .width(85.dp) + .clip(RoundedCornerShape(8.dp)) + .aspectRatio(ratio = GetHeightRatioW85H63), + contentScale = ContentScale.FillBounds, + contentDescription = null, + ) + + // 덕력고사 정보 & 참여율 + Column(modifier = Modifier.padding(start = 8.dp)) { + // 덕력고사 이름 + QuackText(text = title, typography = QuackTypography.HeadLine2) + + // 공백 + Spacer(modifier = Modifier.height(4.dp)) + + Row(horizontalArrangement = Arrangement.Center) { + // 프로필 이미지 + QuackProfileImage( + modifier = Modifier.size(DpSize(width = 16.dp, height = 16.dp)), + profileUrl = profileImageUrl, + ) + + // 닉네임 + 참여 인원수 + QuackText( + modifier = Modifier.padding(start = 4.dp), + text = stringResource( + R.string.home_proceed_item_info, + nickname, + stringResource(id = R.string.home_proceed_item_join_count, joinCount), + ), + typography = QuackTypography.Body2.change(color = QuackColor.Gray1), + ) + } + } + } + + // 공백 + Spacer(modifier = Modifier.height(16.dp)) +} + +/** 전체 보기 버튼 */ +@Composable +private fun ProceedViewAllButton(onViewAllClick: () -> Unit) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(size = 8.dp)) + .quackClickable(onClick = onViewAllClick) + .border( + width = 1.dp, + color = QuackColor.Gray3.value, + shape = RoundedCornerShape(8.dp), + ) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + QuackBody1( + text = stringResource(id = R.string.home_proceed_view_all_button_title), + ) + + Spacer(space = 4.dp) + + QuackImage( + modifier = Modifier.size(DpSize(width = 24.dp, height = 24.dp)), + src = QuackIcon.ArrowRightId, + ) + } +} diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingExamScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingExamScreen.kt index 9d2f6fbb4..3236f9ece 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingExamScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingExamScreen.kt @@ -40,29 +40,30 @@ import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemsIndexed import coil.compose.AsyncImage +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.collectAndHandleState +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage +import team.duckie.app.android.common.compose.ui.skeleton import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.component.HomeTopAppBar import team.duckie.app.android.feature.home.constants.HomeStep +import team.duckie.app.android.feature.home.constants.MainScreenType import team.duckie.app.android.feature.home.viewmodel.home.HomeState import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel -import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.compose.ui.skeleton -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.app.android.common.compose.collectAndHandleState -import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage -import team.duckie.app.android.feature.home.constants.MainScreenType -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackSubtitle2 -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackBody2 +import team.duckie.quackquack.ui.sugar.QuackBody3 +import team.duckie.quackquack.ui.sugar.QuackSubtitle2 internal const val ThumbnailRatio = 4f / 3f private val HomeProfileSize: DpSize = DpSize( - all = 24.dp, + width = 32.dp, + height = 32.dp, ) @Composable @@ -73,6 +74,7 @@ internal fun HomeRecommendFollowingExamScreen( state: HomeState, navigateToCreateProblem: () -> Unit, navigateToHomeDetail: (Int) -> Unit, + navigateToProfile: (Int) -> Unit, ) { val followingExam = vm.followingExam.collectAndHandleState(vm::handleLoadRecommendFollowingState) @@ -105,6 +107,7 @@ internal fun HomeRecommendFollowingExamScreen( followingExam = followingExam, navigateToCreateProblem = navigateToCreateProblem, navigateToHomeDetail = navigateToHomeDetail, + navigateToProfile = navigateToProfile, ) } } @@ -118,6 +121,7 @@ private fun HomeRecommendFollowingSuccessScreen( followingExam: LazyPagingItems, navigateToCreateProblem: () -> Unit, navigateToHomeDetail: (Int) -> Unit, + navigateToProfile: (Int) -> Unit, ) { Box( modifier = Modifier @@ -136,6 +140,7 @@ private fun HomeRecommendFollowingSuccessScreen( vm.changedHomeScreen(HomeStep.toStep(step)) }, onClickedCreate = navigateToCreateProblem, + onClickedNotice = {}, ) } @@ -148,10 +153,10 @@ private fun HomeRecommendFollowingSuccessScreen( title = maker?.title ?: "", tier = maker?.owner?.tier ?: "", favoriteTag = maker?.owner?.favoriteTag ?: "", - onClickUserProfile = { - // TODO(limsaehyun): 마이페이지로 이동 + onUserProfileClick = { + navigateToProfile(maker?.owner?.userId ?: 0) }, - onClickTestCover = { + onTestClick = { navigateToHomeDetail(maker?.examId ?: 0) }, cover = maker?.coverUrl ?: "", @@ -176,9 +181,9 @@ private fun TestCoverWithMaker( name: String, tier: String, favoriteTag: String, - onClickTestCover: () -> Unit, - onClickUserProfile: () -> Unit, + onTestClick: () -> Unit, isLoading: Boolean, + onUserProfileClick: () -> Unit, ) { Column( modifier = modifier, @@ -188,9 +193,7 @@ private fun TestCoverWithMaker( .aspectRatio(ThumbnailRatio) .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .quackClickable { - onClickTestCover() - } + .quackClickable(onClick = onTestClick) .skeleton(isLoading), model = cover, contentDescription = null, @@ -203,8 +206,9 @@ private fun TestCoverWithMaker( name = name, tier = tier, favoriteTag = favoriteTag, - onClickUserProfile = onClickUserProfile, isLoading = isLoading, + onUserProfileClick = onUserProfileClick, + onLayoutClick = onTestClick, ) } } @@ -218,17 +222,22 @@ private fun TestMakerLayout( tier: String, favoriteTag: String, isLoading: Boolean, - onClickUserProfile: (() -> Unit)? = null, + onLayoutClick: () -> Unit, + onUserProfileClick: () -> Unit, ) { Row( - modifier = modifier, + modifier = modifier.quackClickable( + rippleEnabled = false, + ) { + onLayoutClick() + }, verticalAlignment = Alignment.CenterVertically, ) { QuackProfileImage( modifier = Modifier.skeleton(isLoading), profileUrl = profileImageUrl, size = HomeProfileSize, - onClick = onClickUserProfile, + onClick = onUserProfileClick, ) Column(modifier = Modifier.padding(start = 8.dp)) { QuackSubtitle2( @@ -241,10 +250,10 @@ private fun TestMakerLayout( text = name, ) Spacer(modifier = Modifier.width(8.dp)) - QuackBody3( + QuackText( modifier = Modifier.skeleton(isLoading), text = "$tier · $favoriteTag", - color = QuackColor.Gray2, + typography = QuackTypography.Body3.change(color = QuackColor.Gray2), ) } } @@ -271,11 +280,12 @@ private fun HomeFollowingExamNotFoundScreen( onClickedCreate = { navigateToCreateProblem() }, + onClickedNotice = {}, ) Spacer(space = 60.dp) - QuackSubtitle( + QuackText( text = stringResource(id = R.string.home_following_exam_not_found_title), - align = TextAlign.Center, + typography = QuackTypography.Subtitle.change(textAlign = TextAlign.Center), ) Spacer(space = 12.dp) QuackBody2(text = stringResource(id = R.string.home_following_exam_not_found_subtitle)) diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingScreen.kt index e4eda5707..53e64dedc 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingScreen.kt @@ -24,18 +24,19 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider +import team.duckie.app.android.common.kotlin.fastForEach import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.component.HomeTopAppBar import team.duckie.app.android.feature.home.constants.HomeStep +import team.duckie.app.android.feature.home.constants.MainScreenType import team.duckie.app.android.feature.home.viewmodel.home.HomeState import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel -import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider -import team.duckie.app.android.common.compose.ui.UserFollowingLayout -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.app.android.common.kotlin.fastForEach -import team.duckie.app.android.feature.home.constants.MainScreenType -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackTitle2 +import team.duckie.app.android.common.compose.ui.content.UserFollowingLayout +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun HomeRecommendFollowingScreen( @@ -60,6 +61,7 @@ internal fun HomeRecommendFollowingScreen( vm.changedHomeScreen(HomeStep.toStep(step)) }, onClickedCreate = navigateToCreateProblem, + onClickedNotice = {}, ) } @@ -70,9 +72,9 @@ internal fun HomeRecommendFollowingScreen( .height(213.dp), contentAlignment = Alignment.Center, ) { - QuackSubtitle( + QuackText( text = stringResource(id = R.string.home_following_initial_title), - align = TextAlign.Center, + typography = QuackTypography.Subtitle.change(textAlign = TextAlign.Center), ) } } @@ -115,13 +117,13 @@ private fun HomeFollowingInitialRecommendUsers( recommendUser.fastForEach { user -> UserFollowingLayout( userId = user.userId, - isMine = myUserId == user.userId, + visibleTrailingButton = myUserId != user.userId, profileImgUrl = user.profileImgUrl, nickname = user.nickname, favoriteTag = user.favoriteTag, tier = user.tier, isFollowing = user.isFollowing, - onClickFollow = { + onClickTrailingButton = { onClickFollowing(user.userId, it) }, ) diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendScreen.kt index cf7469fcd..1da5fde76 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendScreen.kt @@ -8,6 +8,7 @@ @file:OptIn( ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, + ExperimentalQuackQuackApi::class, ) package team.duckie.app.android.feature.home.screen.home @@ -27,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi @@ -34,6 +36,10 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -47,12 +53,16 @@ import coil.compose.AsyncImage import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.ui.DuckExamSmallCover import team.duckie.app.android.common.compose.ui.DuckTestCoverItem import team.duckie.app.android.common.compose.ui.DuckieHorizontalPagerIndicator import team.duckie.app.android.common.compose.ui.quack.todo.QuackAnnotatedText import team.duckie.app.android.common.compose.ui.skeleton +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.common.kotlin.addHashTag import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.domain.recommendation.model.ExamType @@ -61,13 +71,14 @@ import team.duckie.app.android.feature.home.component.HomeTopAppBar import team.duckie.app.android.feature.home.constants.HomeStep import team.duckie.app.android.feature.home.viewmodel.home.HomeState import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel -import team.duckie.quackquack.ui.component.QuackBody1 -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackLarge1 -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackBody3 +import team.duckie.quackquack.ui.sugar.QuackLarge1 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private val HomeHorizontalPadding = PaddingValues(horizontal = 16.dp) +private const val JumbotronSwipeInterval = 3000L @Composable internal fun HomeRecommendScreen( @@ -79,7 +90,33 @@ internal fun HomeRecommendScreen( navigateToSearch: (String) -> Unit, openExamBottomSheet: (Int) -> Unit, ) { - val pageState = rememberPagerState() + val pageState = rememberPagerState( + pageCount = { state.jumbotrons.size }, + initialPage = state.jumbotronPage, + ) + + val isLocateMiddle by remember { + derivedStateOf { + pageState.currentPageOffsetFraction != 0.0f + } + } + + LaunchedEffect(key1 = Unit) { + if (isLocateMiddle) { + pageState.scrollToPage(state.jumbotronPage) + } + } + + LaunchedEffect(key1 = pageState.currentPage) { + delay(JumbotronSwipeInterval) + withContext(NonCancellable) { + if (pageState.isLastPage) { + pageState.animateScrollToPage(0.also(homeViewModel::saveJumbotronPage)) + } else { + pageState.animateScrollToPage((pageState.currentPage + 1).also(homeViewModel::saveJumbotronPage)) + } + } + } val lazyRecommendations = homeViewModel.recommendations.collectAsLazyPagingItems() @@ -91,8 +128,7 @@ internal fun HomeRecommendScreen( ) Box( - modifier = Modifier - .pullRefresh(pullRefreshState), + modifier = Modifier.pullRefresh(pullRefreshState), ) { LazyColumn( modifier = modifier @@ -110,18 +146,16 @@ internal fun HomeRecommendScreen( onClickedCreate = { navigateToCreateProblem() }, + onClickedNotice = {}, ) } item { - HorizontalPager( - pageCount = state.jumbotrons.size, - state = pageState, - ) { page -> + HorizontalPager(state = pageState) { page -> HomeRecommendJumbotronLayout( modifier = Modifier .padding(HomeHorizontalPadding), - recommendItem = state.jumbotrons[page], + item = state.jumbotrons.getOrNull(page % state.jumbotrons.size), onStartClicked = { examId -> navigateToHomeDetail(examId) }, @@ -167,13 +201,16 @@ internal fun HomeRecommendScreen( } } +private val PagerState.isLastPage: Boolean + get() = currentPage == pageCount - 1 + @Composable private fun HomeRecommendJumbotronLayout( modifier: Modifier = Modifier, - recommendItem: HomeState.HomeRecommendJumbotron, + item: HomeState.HomeRecommendJumbotron?, onStartClicked: (Int) -> Unit, isLoading: Boolean, -) { +) = item?.let { recommendItem -> Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -194,18 +231,19 @@ private fun HomeRecommendJumbotronLayout( text = recommendItem.title, ) Spacer(modifier = Modifier.height(12.dp)) - QuackBody1( + QuackText( modifier = Modifier.skeleton(isLoading), text = recommendItem.content, - align = TextAlign.Center, + typography = QuackTypography.Body1.change(textAlign = TextAlign.Center), ) Spacer(modifier = Modifier.height(24.dp)) - QuackLargeButton( - modifier = Modifier.skeleton(isLoading), - type = QuackLargeButtonType.Fill, + + TempFlexiblePrimaryLargeButton( + modifier = Modifier + .fillMaxWidth() + .skeleton(isLoading), text = recommendItem.buttonContent, onClick = { onStartClicked(recommendItem.examId) }, - enabled = true, ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeScreen.kt index 2c78cb521..95b13e3e6 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeScreen.kt @@ -25,15 +25,15 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import okhttp3.internal.immutableListOf import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.home.constants.HomeStep -import team.duckie.app.android.feature.home.viewmodel.home.HomeSideEffect -import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel +import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableBottomSheetDialog -import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade -import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableType +import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade +import team.duckie.app.android.feature.home.constants.HomeStep import team.duckie.app.android.feature.home.constants.MainScreenType +import team.duckie.app.android.feature.home.viewmodel.home.HomeSideEffect +import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel private val HomeHorizontalPadding = PaddingValues(horizontal = 16.dp) @@ -47,6 +47,7 @@ internal fun HomeScreen( navigateToCreateProblem: () -> Unit, navigateToHomeDetail: (Int) -> Unit, navigateToSearch: (String) -> Unit, + navigateToProfile: (Int) -> Unit, ) { val state = vm.collectAsState().value val bottomSheetDialogState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) @@ -106,6 +107,20 @@ internal fun HomeScreen( }, ) + page == HomeStep.HomeProceedScreen -> HomeProceedScreen( + state = state, + homeViewModel = vm, + navigateToCreateProblem = navigateToCreateProblem, + navigateToHomeDetail = navigateToHomeDetail, + navigateToSearch = navigateToSearch, + openExamBottomSheet = { exam -> + setTargetExamId(exam) + coroutineScope.launch { + bottomSheetDialogState.show() + } + }, + ) + page == HomeStep.HomeFollowingScreen -> if (state.isFollowingExist) { HomeRecommendFollowingExamScreen( initState = initState, @@ -113,6 +128,7 @@ internal fun HomeScreen( state = state, navigateToHomeDetail = navigateToHomeDetail, navigateToCreateProblem = navigateToCreateProblem, + navigateToProfile = navigateToProfile, ) } else { HomeRecommendFollowingScreen( diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/mypage/MypageScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/mypage/MypageScreen.kt index 0f05aa9db..9d8e1ec4c 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/mypage/MypageScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/mypage/MypageScreen.kt @@ -44,7 +44,7 @@ import team.duckie.app.android.feature.home.viewmodel.mypage.MyPageViewModel import team.duckie.app.android.feature.profile.screen.MyProfileScreen import team.duckie.app.android.feature.profile.viewmodel.state.ExamType import team.duckie.app.android.feature.profile.viewmodel.state.ProfileStep -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor @Composable internal fun MyPageScreen( @@ -147,7 +147,7 @@ internal fun MyPageScreen( MyProfileScreen( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor), + .background(color = QuackColor.White.value), userProfile = state.userProfile, isLoading = state.isLoading, onClickSetting = viewModel::clickSetting, diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamSection.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamSection.kt index 00ddd7eb4..ce6415206 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamSection.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamSection.kt @@ -41,15 +41,15 @@ import team.duckie.app.android.common.compose.ui.DuckExamSmallCoverForColumn import team.duckie.app.android.common.compose.ui.DuckTestCoverItem import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.TextTabLayout +import team.duckie.app.android.common.compose.ui.quack.todo.QuackOutLinedSingeLazyRowTag import team.duckie.app.android.common.compose.ui.skeleton import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.viewmodel.ranking.RankingViewModel +import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSingeLazyRowTag -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.component.QuackTitle2 +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackTitle2 import team.duckie.quackquack.material.QuackColor as QuackV2Color @Composable @@ -78,13 +78,13 @@ internal fun ExamSection( .padding(top = 10.dp), ) { // TODO:(EvergreenTree97) 태그의 inner padding이 바뀌어야 함 - QuackSingeLazyRowTag( + QuackOutLinedSingeLazyRowTag( modifier = Modifier .fillMaxWidth() .skeleton(state.isTagLoading), items = tagNames, itemSelections = state.tagSelections, - tagType = QuackTagType.Circle(), + trailingIcon = null, onClick = viewModel::changeSelectedTags, ) Spacer(space = 28.dp) @@ -196,13 +196,13 @@ private fun RankingEdge(rank: Int) { ), contentAlignment = Alignment.Center, ) { - QuackTitle2( + QuackText( modifier = Modifier.padding( horizontal = 9.dp, vertical = 2.dp, ), - color = QuackColor.White, text = rank.toString(), + typography = QuackTypography.Title2.change(color = QuackColor.White), ) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamineeSection.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamineeSection.kt index 1628f6013..1975f0442 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamineeSection.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamineeSection.kt @@ -16,28 +16,28 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemsIndexed import team.duckie.app.android.common.compose.itemsIndexedPagingKey +import team.duckie.app.android.common.compose.ui.DuckieDivider import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage import team.duckie.app.android.common.compose.ui.skeleton import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.feature.home.viewmodel.ranking.RankingViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.sugar.QuackBody2 +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun ExamineeSection( @@ -104,7 +104,10 @@ private fun ExamineeContent( QuackProfileImage( modifier = Modifier.skeleton(isLoading), profileUrl = profileImageUrl, - size = DpSize(all = 44.dp), + size = DpSize( + width = 44.dp, + height = 44.dp, + ), ) Spacer(space = 8.dp) QuackTitle2( @@ -127,6 +130,6 @@ private fun ExamineeContent( } } } - Divider(color = QuackColor.Gray4.composeColor) + DuckieDivider(color = QuackColor.Gray4) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/RankingScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/RankingScreen.kt index 7e8fd4622..44d582b19 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/RankingScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/RankingScreen.kt @@ -44,16 +44,17 @@ import com.google.firebase.ktx.Firebase import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import team.duckie.app.android.common.compose.ui.ErrorScreen +import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableBottomSheetDialog +import team.duckie.app.android.common.kotlin.AllowMagicNumber +import team.duckie.app.android.common.kotlin.fastForEach import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.component.HeadLineTopAppBar +import team.duckie.app.android.feature.home.constants.MainScreenType import team.duckie.app.android.feature.home.constants.RankingPage import team.duckie.app.android.feature.home.viewmodel.ranking.RankingSideEffect import team.duckie.app.android.feature.home.viewmodel.ranking.RankingViewModel -import team.duckie.app.android.common.compose.ui.ErrorScreen -import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableBottomSheetDialog -import team.duckie.app.android.common.kotlin.AllowMagicNumber -import team.duckie.app.android.feature.home.constants.MainScreenType -import team.duckie.quackquack.ui.component.QuackMainTab +import team.duckie.quackquack.ui.QuackTab @Composable internal fun RankingScreen( @@ -73,7 +74,10 @@ internal fun RankingScreen( context.getString(R.string.exam), ) } - val pagerState = rememberPagerState(initialPage = state.selectedTab) + val pagerState = rememberPagerState( + initialPage = state.selectedTab, + pageCount = { tabs.size }, + ) val coroutineScope = rememberCoroutineScope() val lazyListState = rememberLazyListState() val lazyGridState = rememberLazyGridState() @@ -171,20 +175,19 @@ internal fun RankingScreen( onRetryClick = viewModel::refresh, ) } else { - QuackMainTab( - titles = tabs, - selectedTabIndex = state.selectedTab, - onTabSelected = { - viewModel.setSelectedTab(it) - coroutineScope.launch { - pagerState.animateScrollToPage(it) + QuackTab(index = state.selectedTab) { + tabs.fastForEach { label -> + tab(label) { index -> + viewModel.setSelectedTab(index) + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } } - }, - ) + } + } HorizontalPager( modifier = Modifier.fillMaxSize(), state = pagerState, - pageCount = tabs.size, key = { tabs[it] }, ) { page -> when (page) { diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/search/SearchMainScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/search/SearchMainScreen.kt index 29d91f7dd..6efa25ac0 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/search/SearchMainScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/search/SearchMainScreen.kt @@ -5,19 +5,27 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(ExperimentalMaterialApi::class) +@file:OptIn( + ExperimentalMaterialApi::class, + ExperimentalLayoutApi::class, + ExperimentalQuackQuackApi::class, +) package team.duckie.app.android.feature.home.screen.search import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row 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.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi @@ -34,26 +42,27 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.home.R -import team.duckie.app.android.feature.home.viewmodel.MainViewModel import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.icon.v1.SearchId import team.duckie.app.android.common.kotlin.AllowMagicNumber +import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.constants.MainScreenType +import team.duckie.app.android.feature.home.viewmodel.MainViewModel import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle import team.duckie.quackquack.ui.QuackText - import team.duckie.quackquack.ui.sugar.QuackTitle2 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackLazyVerticalGridTag -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private val SearchScreenHorizontalPaddingDp = 16.dp @@ -102,12 +111,16 @@ internal fun SearchMainScreen( verticalAlignment = Alignment.CenterVertically, ) { QuackImage( - src = QuackIcon.Search, - size = DpSize(24.dp), + modifier = Modifier.size(DpSize(width = 24.dp, height = 24.dp)), + src = QuackIcon.SearchId, ) Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier + .quackClickable( + rippleEnabled = false, + onClick = vm::navigateToSearch, + ) .fillMaxWidth() .height(36.dp) .background( @@ -116,11 +129,6 @@ internal fun SearchMainScreen( size = 8.dp, ), ) - .quackClickable( - rippleEnabled = false, - ) { - vm.navigateToSearch() - } .padding(start = 12.dp), contentAlignment = Alignment.CenterStart, ) { @@ -132,22 +140,30 @@ internal fun SearchMainScreen( ) } } - Spacer(modifier = Modifier.height(22.dp)) + Spacer(modifier = Modifier.height(16.dp)) QuackTitle2( modifier = Modifier.padding(start = SearchScreenHorizontalPaddingDp), text = stringResource(id = R.string.popular_tag), ) - Spacer(modifier = Modifier.height(8.dp)) - // TODO(limsaehyun): 추후 꽥꽥에서, 전체 너비만큼 태그 Composable 을 넣을 수 있는 Composable 적용 필요 - QuackLazyVerticalGridTag( + Spacer(modifier = Modifier.height(12.dp)) + FlowRow( modifier = Modifier.padding(horizontal = SearchScreenHorizontalPaddingDp), - items = state.popularTags.map { it.name }, - tagType = QuackTagType.Round, - onClick = { index -> - vm.navigateToSearch(searchTag = state.popularTags[index].name) - }, - itemChunkedSize = 5, - ) + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + state.popularTags.forEach { tag -> + QuackTag( + text = tag.name, + style = QuackTagStyle.Filled, + selected = false, + ) { + vm.navigateToSearch( + searchTag = tag.name, + autoFocusing = false, + ) + } + } + } } PullRefreshIndicator( diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainSideEffect.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainSideEffect.kt index d73866cca..17462f146 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainSideEffect.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainSideEffect.kt @@ -22,7 +22,7 @@ internal sealed class MainSideEffect { /** * [SearchResultActivity] 로 이동하는 SideEffect 입니다. */ - class NavigateToSearch(val searchTag: String?) : MainSideEffect() + class NavigateToSearch(val searchTag: String?, val autoFocusing: Boolean) : MainSideEffect() /** * [HomeDetailActivity] 로 이동하는 SideEffect 입니다. @@ -35,12 +35,14 @@ internal sealed class MainSideEffect { object NavigateToSetting : MainSideEffect() /** - * [CreateProblemActivity] 로 이동하는 SideEffect 입니다. + * [CreateExamActivity] 로 이동하는 SideEffect 입니다. */ object NavigateToCreateProblem : MainSideEffect() object NavigateToNotification : MainSideEffect() + data class NavigateToProfile(val userId: Int) : MainSideEffect() + object ClickRankingRetry : MainSideEffect() class NavigateToFriends(val friendType: FriendsType, val myUserId: Int, val nickname: String) : diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainViewModel.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainViewModel.kt index 670f1540a..d00d66599 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainViewModel.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainViewModel.kt @@ -16,12 +16,12 @@ import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container import team.duckie.app.android.common.android.ui.const.Extras -import team.duckie.app.android.domain.report.usecase.ReportUseCase -import team.duckie.app.android.domain.tag.usecase.FetchPopularTagsUseCase -import team.duckie.app.android.feature.home.constants.BottomNavigationStep import team.duckie.app.android.common.compose.ui.dialog.ReportAlreadyExists import team.duckie.app.android.common.kotlin.FriendsType import team.duckie.app.android.common.kotlin.exception.isReportAlreadyExists +import team.duckie.app.android.domain.report.usecase.ReportUseCase +import team.duckie.app.android.domain.tag.usecase.FetchPopularTagsUseCase +import team.duckie.app.android.feature.home.constants.BottomNavigationStep import team.duckie.app.android.feature.home.constants.MainScreenType import javax.inject.Inject @@ -136,8 +136,9 @@ internal class MainViewModel @Inject constructor( /** 검색 화면으로 이동한다 */ fun navigateToSearch( searchTag: String? = null, + autoFocusing: Boolean = true, ) = intent { - postSideEffect(MainSideEffect.NavigateToSearch(searchTag)) + postSideEffect(MainSideEffect.NavigateToSearch(searchTag, autoFocusing)) } /** 홈 디테일 화면으로 이동한다 */ @@ -171,6 +172,10 @@ internal class MainViewModel @Inject constructor( postSideEffect(MainSideEffect.NavigateToNotification) } + fun navigateToProfile(userId: Int) = intent { + postSideEffect(MainSideEffect.NavigateToProfile(userId)) + } + /** 온보딩(가이드) 활성화 여부를 업데이트한다 */ fun updateGuideVisible(visible: Boolean) = intent { reduce { state.copy(guideVisible = visible) } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeState.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeState.kt index 38f294840..b74a5f056 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeState.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeState.kt @@ -21,14 +21,17 @@ internal data class HomeState( val isHomeRecommendLoading: Boolean = false, val isHomeRecommendFollowingExamLoading: Boolean = false, + val isHomeProceedLoading: Boolean = false, val isHomeRecommendFollowingExamRefreshLoading: Boolean = false, val isHomeRecommendPullRefreshLoading: Boolean = false, + val isHomeProceedPullRefreshLoading: Boolean = false, val homeSelectedIndex: HomeStep = HomeStep.HomeRecommendScreen, val jumbotrons: ImmutableList = skeletonJumbotrons, val recommendTopics: ImmutableList = persistentListOf(), + val jumbotronPage: Int = 0, val isFollowingExist: Boolean = true, val recommendFollowing: ImmutableList = persistentListOf(), @@ -60,13 +63,14 @@ internal data class HomeState( val profileImgUrl: String, val favoriteTag: String, val tier: String, + val userId: Int, ) { /** * [User] 의 Empty Model 입니다. * 초기화 혹은 Skeleton UI 등에 필요한 Mock Data 로 쓰입니다. */ companion object { - fun empty() = User("", "", "", "") + fun empty() = User("", "", "", "", 0) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeViewModel.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeViewModel.kt index abe5d29d8..a098a0f55 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeViewModel.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeViewModel.kt @@ -26,6 +26,9 @@ import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import team.duckie.app.android.common.kotlin.exception.isFollowingAlreadyExists +import team.duckie.app.android.common.kotlin.exception.isFollowingNotFound +import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.domain.follow.model.FollowBody import team.duckie.app.android.domain.follow.usecase.FollowUseCase @@ -40,9 +43,6 @@ import team.duckie.app.android.feature.home.constants.HomeStep import team.duckie.app.android.feature.home.viewmodel.mapper.toFollowingModel import team.duckie.app.android.feature.home.viewmodel.mapper.toJumbotronModel import team.duckie.app.android.feature.home.viewmodel.mapper.toUiModel -import team.duckie.app.android.common.kotlin.exception.isFollowingAlreadyExists -import team.duckie.app.android.common.kotlin.exception.isFollowingNotFound -import team.duckie.app.android.common.kotlin.fastMap import javax.inject.Inject @HiltViewModel @@ -110,6 +110,12 @@ internal class HomeViewModel @Inject constructor( } } + fun saveJumbotronPage(page: Int) = intent { + reduce { + state.copy(jumbotronPage = page) + } + } + /** * 팔로잉 추천 탭을 새로고침한다. * [forceLoading] - PullRefresh 를 할 경우 사용자에게 새로고침이 됐음을 알리기 위한 최소한의 로딩 시간을 부여한다. @@ -125,6 +131,21 @@ internal class HomeViewModel @Inject constructor( } } + /** + * 진행중 탭을 새로고침한다. + * + * [forceLoading] - PullRefresh 를 할 경우 사용자에게 새로고침이 됐음을 알리기 위한 최소한의 로딩 시간을 부여한다. + */ + fun refreshProceeds(forceLoading: Boolean = false) { + viewModelScope.launch { + updateHomeProceedRefreshLoading(true) + // TODO(riflockle7): 진행중 시험 API 로직 필요 + // fetchRecommendFollowingExam() + if (forceLoading) delay(pullToRefreshMinLoadingDelay) + updateHomeProceedRefreshLoading(false) + } + } + /** 홈 화면의 jumbotron을 가져온다. */ internal fun fetchJumbotrons() = intent { startHomeRecommendLoading() @@ -135,7 +156,7 @@ internal class HomeViewModel @Inject constructor( isHomeRecommendLoading = false, jumbotrons = jumbotrons .fastMap(Exam::toJumbotronModel) - .toPersistentList(), + .toImmutableList(), ) } }.onFailure { exception -> @@ -288,4 +309,17 @@ internal class HomeViewModel @Inject constructor( ) } } + + /** 홈 화면의 진행중 탭의 pull refresh 로딩 상태를 [loading]으로 바꾼다. */ + private fun updateHomeProceedRefreshLoading( + loading: Boolean, + ) = intent { + reduce { + state.copy( + isHomeProceedPullRefreshLoading = loading, + isHomeProceedLoading = loading, // for skeleton UI + isError = false, + ) + } + } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/mapper/mapper.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/mapper/mapper.kt index c7e7aa680..bf59c773d 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/mapper/mapper.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/mapper/mapper.kt @@ -50,5 +50,6 @@ internal fun Exam.toFollowingModel() = profileImgUrl = user?.profileImageUrl ?: "", favoriteTag = user?.duckPower?.tag?.name ?: "", tier = user?.duckPower?.tier ?: "", + userId = user?.id ?: 0, ), ) diff --git a/feature/home/src/main/res/drawable/home_ic_notice.png b/feature/home/src/main/res/drawable/home_ic_notice.png new file mode 100644 index 000000000..9b34af7d1 Binary files /dev/null and b/feature/home/src/main/res/drawable/home_ic_notice.png differ diff --git a/feature/home/src/main/res/drawable/home_proceed_banner_right.png b/feature/home/src/main/res/drawable/home_proceed_banner_right.png new file mode 100644 index 000000000..9c725dd1e Binary files /dev/null and b/feature/home/src/main/res/drawable/home_proceed_banner_right.png differ diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index ecb90714c..f29d0b502 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ 팔로우 응시자 추천 + 진행중 "관심있는 키워드를 검색해보세요!" 인기 태그 @@ -66,4 +67,15 @@ 팔로우한 유저 중에\n아직 올라온 덕력고사가 없어요. 다른 유저들을 더 찾아보세요! + + + 출제 임박 덕력고사 + 오픈까지 %s문제! + %s | %s + %s명 참여 + %s/%s + 전체보기 + 직접 덕력고사를 열고 싶다면? + 출제 제안하기 + %s,\n이런 덕력고사도 있덕 diff --git a/feature/notification/build.gradle.kts b/feature/notification/build.gradle.kts index 947c7415e..51df2b689 100644 --- a/feature/notification/build.gradle.kts +++ b/feature/notification/build.gradle.kts @@ -28,9 +28,10 @@ dependencies { projects.common.compose, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for CircularProgressIndicator libs.firebase.crashlytics, + libs.quack.v2.ui, + libs.kotlin.collections.immutable, ) } diff --git a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/NotificationActivity.kt b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/NotificationActivity.kt index c39f56244..e0d03fb23 100644 --- a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/NotificationActivity.kt +++ b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/NotificationActivity.kt @@ -22,13 +22,13 @@ import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase import dagger.hilt.android.AndroidEntryPoint import org.orbitmvi.orbit.viewmodel.observe +import team.duckie.app.android.common.android.ui.BaseActivity +import team.duckie.app.android.common.android.ui.finishWithAnimation import team.duckie.app.android.feature.notification.screen.NotificationScreen import team.duckie.app.android.feature.notification.viewmodel.NotificationSideEffect import team.duckie.app.android.feature.notification.viewmodel.NotificationViewModel import team.duckie.app.android.navigator.feature.home.HomeNavigator -import team.duckie.app.android.common.android.ui.BaseActivity -import team.duckie.app.android.common.android.ui.finishWithAnimation -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor import javax.inject.Inject @AndroidEntryPoint @@ -48,11 +48,10 @@ class NotificationActivity : BaseActivity() { NotificationScreen( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .systemBarsPadding() .navigationBarsPadding() - .padding(top = 12.dp) - .padding(horizontal = 12.dp), + .padding(horizontal = 16.dp), ) } viewModel.observe( diff --git a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/screen/NoticationScreen.kt b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/screen/NoticationScreen.kt index 5be73745a..db0f5f865 100644 --- a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/screen/NoticationScreen.kt +++ b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/screen/NoticationScreen.kt @@ -8,55 +8,46 @@ package team.duckie.app.android.feature.notification.screen import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import team.duckie.app.android.feature.notification.R -import team.duckie.app.android.feature.notification.viewmodel.NotificationViewModel -import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.NoItemScreen -import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade +import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.skeleton -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.app.android.common.kotlin.getDiffDayFromToday -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.shape.SquircleShape -import team.duckie.quackquack.ui.util.DpSize -import java.util.Date +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.material.shape.SquircleShape +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.sugar.QuackBody2 +import team.duckie.quackquack.ui.sugar.QuackBody3 @Composable internal fun NotificationScreen( modifier: Modifier = Modifier, - viewModel: NotificationViewModel = activityViewModel(), + // viewModel: NotificationViewModel = activityViewModel(), ) { - val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - viewModel.getNotifications() - } + // val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() Column(modifier = modifier) { - QuackTopAppBar( - leadingIcon = QuackIcon.ArrowBack, + // TODO(EvergreenTree97) : 알림 화면 구현 후 제거 + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(space = 120.dp) + NoItemScreen( + title = "아직 알림기능이 준비중입니다", + description = "조금만 기다려주세요:)", + ) + } + /*QuackTopAppBar( + leadingIcon = QuackIcon.Outlined.ArrowBack, leadingText = stringResource(id = R.string.notification), onLeadingIconClick = viewModel::clickBackPress, ) @@ -74,10 +65,12 @@ internal fun NotificationScreen( } isEmpty -> { - Box( + Column( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, ) { + Spacer(space = 120.dp) NoItemScreen( title = stringResource(id = R.string.empty_notfications), description = stringResource(id = R.string.check_notifications_after_activity), @@ -110,15 +103,16 @@ internal fun NotificationScreen( } } } - } + }*/ } } +@Suppress("UnusedPrivateMember") @Composable private fun NotificationItem( thumbnailUrl: String, body: String, - createdAt: Date, + createdAt: String, isLoading: Boolean, onClick: () -> Unit, ) { @@ -129,12 +123,13 @@ private fun NotificationItem( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { QuackImage( - modifier = Modifier.skeleton( - visible = isLoading, - shape = SquircleShape, - ), + modifier = Modifier + .size(36.dp) + .skeleton( + visible = isLoading, + shape = SquircleShape, + ), src = thumbnailUrl, - size = DpSize(all = 36.dp), ) Column( modifier = Modifier.fillMaxWidth(), @@ -146,7 +141,7 @@ private fun NotificationItem( ) QuackBody3( modifier = Modifier.skeleton(visible = isLoading), - text = createdAt.getDiffDayFromToday(), + text = createdAt, ) } } diff --git a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/viewmodel/NotificationViewModel.kt b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/viewmodel/NotificationViewModel.kt index 27e21187e..bb17fb18a 100644 --- a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/viewmodel/NotificationViewModel.kt +++ b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/viewmodel/NotificationViewModel.kt @@ -9,6 +9,7 @@ package team.duckie.app.android.feature.notification.viewmodel import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent @@ -29,27 +30,40 @@ internal class NotificationViewModel @Inject constructor( override val container = container(NotificationState()) - fun getNotifications() = intent { - startLoading() - getNotificationsUseCase().onSuccess { notifications -> + init { + // TODO(EvergreenTree97) : 알림 기능 구현 후 제거 + intent { reduce { state.copy( isLoading = false, - isError = false, - notifications = notifications.toImmutableList(), - ) - } - }.onFailure { - reduce { - state.copy( - isLoading = false, - isError = true, - notifications = emptyList().toImmutableList(), + notifications = persistentListOf(), ) } } } + fun getNotifications() = intent { + startLoading() + getNotificationsUseCase() + .onSuccess { notifications -> + reduce { + state.copy( + isLoading = false, + isError = false, + notifications = notifications.toImmutableList(), + ) + } + }.onFailure { + reduce { + state.copy( + isLoading = false, + isError = true, + notifications = emptyList().toImmutableList(), + ) + } + } + } + fun clickBackPress() = intent { postSideEffect(NotificationSideEffect.FinishActivity) } fun clickNotification(id: Int) = intent { diff --git a/feature/onboard/build.gradle.kts b/feature/onboard/build.gradle.kts index 0b04a5fc5..9b980e926 100644 --- a/feature/onboard/build.gradle.kts +++ b/feature/onboard/build.gradle.kts @@ -33,8 +33,10 @@ dependencies { libs.orbit.compose, libs.ktx.lifecycle.runtime, libs.compose.ui.material, // needs for ModalBottomSheet + libs.compose.ui.foundation, libs.compose.lifecycle.runtime, libs.compose.ui.accompanist.flowlayout, - libs.quack.ui.components, + libs.kotlin.collections.immutable, + libs.quack.v2.ui, ) } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/OnboardActivity.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/OnboardActivity.kt index 2569c5e4c..97553a60d 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/OnboardActivity.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/OnboardActivity.kt @@ -14,6 +14,7 @@ import androidx.activity.addCallback import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.getValue @@ -49,9 +50,8 @@ import team.duckie.app.android.feature.onboard.screen.TagScreen import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.feature.onboard.viewmodel.sideeffect.OnboardSideEffect import team.duckie.app.android.feature.onboard.viewmodel.state.OnboardState -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint @@ -129,11 +129,12 @@ class OnboardActivity : BaseActivity() { setContent { QuackTheme { - QuackAnimatedContent( + AnimatedContent( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor), + .background(color = QuackColor.White.value), targetState = onboardStepState, + label = "AnimatedContent", ) { onboardStep -> when (onboardStep) { OnboardStep.Activity, OnboardStep.Login -> LoginScreen() diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/OnboardTopAppBar.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/OnboardTopAppBar.kt index 9bbadcd32..2f58439c4 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/OnboardTopAppBar.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/OnboardTopAppBar.kt @@ -10,27 +10,24 @@ package team.duckie.app.android.feature.onboard.common import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import team.duckie.app.android.feature.onboard.constant.OnboardStep -import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.systemBarPaddings -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar +import team.duckie.app.android.feature.onboard.constant.OnboardStep +import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack @Composable internal fun OnboardTopAppBar( modifier: Modifier = Modifier, currentStep: OnboardStep, - horizontalPadding: Dp = 20.dp, vm: OnboardViewModel = activityViewModel(), ) { QuackTopAppBar( - modifier = modifier - .padding(top = systemBarPaddings.calculateTopPadding()) - .padding(horizontal = horizontalPadding - 8.dp), // 내부에서 8.dp 가 들어감 - leadingIcon = QuackIcon.ArrowBack, + modifier = modifier.padding(top = systemBarPaddings.calculateTopPadding()), + leadingIcon = QuackIcon.Outlined.ArrowBack, onLeadingIconClick = { vm.navigateStep(currentStep - 1) }, ) } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/TitleAndDescription.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/TitleAndDescription.kt index 9017b098b..47ee9a00b 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/TitleAndDescription.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/TitleAndDescription.kt @@ -15,8 +15,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.component.QuackBody1 -import team.duckie.quackquack.ui.component.QuackHeadLine1 +import team.duckie.quackquack.ui.sugar.QuackBody1 +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 @Composable internal fun TitleAndDescription( diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/0_LoginScreen.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/0_LoginScreen.kt index 2e82a3f23..4768fc819 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/0_LoginScreen.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/0_LoginScreen.kt @@ -20,9 +20,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -35,23 +33,20 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import kotlinx.coroutines.launch -import team.duckie.app.android.feature.onboard.R -import team.duckie.app.android.feature.onboard.constant.OnboardStep -import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.asLoose import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.kotlin.fastFirstOrNull import team.duckie.app.android.common.kotlin.npe -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.app.android.feature.onboard.R +import team.duckie.app.android.feature.onboard.constant.OnboardStep +import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText @Suppress("UnusedPrivateMember", "unused") private val currentStep = OnboardStep.Login @@ -125,21 +120,25 @@ private fun LoginScreenWelcome() { ), ) { QuackImage( - src = R.drawable.img_duckie_talk, - size = DpSize( + modifier = Modifier.size( width = 180.dp, height = 248.dp, ), + src = R.drawable.img_duckie_talk, ) - QuackHeadLine2( + QuackText( + modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(R.string.kakaologin_welcome_message), - align = TextAlign.Center, + typography = QuackTypography.HeadLine2.change( + textAlign = TextAlign.Center, + ), ) } } private const val LoginScreenLoginAreaKakaoSymbolLayoutId = "LoginScreenLoginAreaKakaoSymbol" -private const val LoginScreenLoginAreaKakaoLoginLabelLayoutId = "LoginScreenLoginAreaKakaoLoginLabel" +private const val LoginScreenLoginAreaKakaoLoginLabelLayoutId = + "LoginScreenLoginAreaKakaoLoginLabel" private val LoginScreenLoginAreaMeasurePolicy = MeasurePolicy { measurables, constraints -> val extraLooseConstraints = constraints.asLoose(width = true) @@ -180,7 +179,6 @@ private val LoginScreenLoginAreaMeasurePolicy = MeasurePolicy { measurables, con @Composable private fun LoginScreenLoginArea(vm: OnboardViewModel = activityViewModel()) { val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() Column( modifier = Modifier.layoutId(LoginScreenLoginAreaLayoutId), @@ -203,9 +201,7 @@ private fun LoginScreenLoginArea(vm: OnboardViewModel = activityViewModel()) { ), ) .clickable { - coroutineScope.launch { - vm.getKakaoAccessTokenAndJoin() - } + vm.getKakaoAccessTokenAndJoin() }, content = { Image( @@ -224,25 +220,25 @@ private fun LoginScreenLoginArea(vm: OnboardViewModel = activityViewModel()) { contentDescription = null, ) // QuackColor 생성자가 internal 이라 BasicText 사용 - BasicText( + QuackText( modifier = Modifier.layoutId(LoginScreenLoginAreaKakaoLoginLabelLayoutId), text = stringResource(R.string.kakaologin_button_label), - style = QuackTextStyle.HeadLine2.asComposeStyle().copy( - color = Color( - ContextCompat.getColor( - context, - R.color.kakao_login_button_label, - ), + typography = QuackTypography.HeadLine2.change( + color = QuackColor( + Color(ContextCompat.getColor(context, R.color.kakao_login_button_label)), ), ), ) }, measurePolicy = LoginScreenLoginAreaMeasurePolicy, ) - QuackBody3( + QuackText( + modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(R.string.kakaologin_login_terms), - color = QuackColor.Gray2, - align = TextAlign.Center, + typography = QuackTypography.Body3.change( + color = QuackColor.Gray2, + textAlign = TextAlign.Center, + ), ) } } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/1_ProfileScreen.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/1_ProfileScreen.kt index ee77f9d61..cc3098cd8 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/1_ProfileScreen.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/1_ProfileScreen.kt @@ -5,7 +5,12 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(FlowPreview::class, ExperimentalComposeUiApi::class) +@file:OptIn( + FlowPreview::class, + ExperimentalComposeUiApi::class, + ExperimentalQuackQuackApi::class, + ExperimentalDesignToken::class, +) @file:Suppress("ConstPropertyName", "PrivatePropertyName") package team.duckie.app.android.feature.onboard.screen @@ -14,9 +19,12 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.launch +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardActions @@ -35,13 +43,11 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.MeasurePolicy -import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.net.toUri @@ -53,102 +59,34 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.android.ui.const.Debounce +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.rememberToast +import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.ImeSpacer import team.duckie.app.android.common.compose.ui.PhotoPicker import team.duckie.app.android.common.compose.ui.PhotoPickerConstants +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.constant.SharedIcon +import team.duckie.app.android.common.compose.ui.quack.todo.QuackErrorableTextField +import team.duckie.app.android.common.compose.ui.quack.todo.QuackErrorableTextFieldState +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton +import team.duckie.app.android.common.kotlin.runIf import team.duckie.app.android.feature.onboard.R import team.duckie.app.android.feature.onboard.common.OnboardTopAppBar import team.duckie.app.android.feature.onboard.common.TitleAndDescription import team.duckie.app.android.feature.onboard.constant.OnboardStep import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.feature.onboard.viewmodel.state.ProfileScreenState -import team.duckie.app.android.common.compose.ui.constant.SharedIcon -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.app.android.common.compose.asLoose -import team.duckie.app.android.common.compose.rememberToast -import team.duckie.app.android.common.compose.systemBarPaddings -import team.duckie.app.android.common.kotlin.fastFirstOrNull -import team.duckie.app.android.common.kotlin.npe -import team.duckie.app.android.common.kotlin.runIf -import team.duckie.app.android.common.android.ui.const.Debounce -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackErrorableTextField -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.shape.SquircleShape -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.material.shape.SquircleShape +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private val currentStep = OnboardStep.Profile -private const val ProfileScreenTopAppBarLayoutId = "ProfileScreenTopAppBar" -private const val ProfileScreenTitleAndDescriptionLayoutId = "ProfileScreenTitleAndDescription" -private const val ProfileScreenProfileImageLayoutId = "ProfileScreenProfileImage" -private const val ProfileScreenNicknameTextFieldLayoutId = "ProfileScreenNicknameTextField" -private const val ProfileScreenNextButtonLayoutId = "ProfileScreenNextButton" - -private val ProfileScreenMeasurePolicy = MeasurePolicy { measurables, constraints -> - val looseConstraints = constraints.asLoose() - val extraLooseConstraints = constraints.asLoose(width = true) - - val topAppBarPlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenTopAppBarLayoutId - }?.measure(looseConstraints) ?: npe() - - val titileAndDescriptionPlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenTitleAndDescriptionLayoutId - }?.measure(looseConstraints) ?: npe() - - val profileImagePlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenProfileImageLayoutId - }?.measure(extraLooseConstraints) ?: npe() - - val nicknameTextFieldPlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenNicknameTextFieldLayoutId - }?.measure(looseConstraints) ?: npe() - - val nextButtonPlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenNextButtonLayoutId - }?.measure(looseConstraints) ?: npe() - - val topAppBarHeight = topAppBarPlaceable.height - val titleAndDescriptionHeight = titileAndDescriptionPlaceable.height - val profileImageHeight = profileImagePlaceable.height - val nextButtonHeight = nextButtonPlaceable.height - - layout( - width = constraints.maxWidth, - height = constraints.maxHeight, - ) { - topAppBarPlaceable.place( - x = 0, - y = 0, - ) - titileAndDescriptionPlaceable.place( - x = 0, - y = topAppBarHeight, - ) - profileImagePlaceable.place( - x = Alignment.CenterHorizontally.align( - size = profileImagePlaceable.width, - space = constraints.maxWidth, - layoutDirection = layoutDirection, - ), - y = topAppBarHeight + titleAndDescriptionHeight, - ) - nicknameTextFieldPlaceable.place( - x = 0, - y = topAppBarHeight + titleAndDescriptionHeight + profileImageHeight, - ) - nextButtonPlaceable.place( - x = 0, - y = constraints.maxHeight - nextButtonHeight, - ) - } -} - private const val MaxNicknameLength = 10 @Composable @@ -204,11 +142,11 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { }, ) } - var lastErrorText by remember { mutableStateOf("") } LaunchedEffect(vm) { val nicknameInputFlow = snapshotFlow { nickname } nicknameInputFlow + .onEach { vm.nicknameChecking() } .onEach { vm.readyToScreenCheck(currentStep) } .debounce(Debounce.SearchSecond) .collect { nickname -> @@ -221,39 +159,31 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { } Box(modifier = Modifier.fillMaxSize()) { - Layout( + Column( modifier = Modifier .zIndex(1f) .fillMaxSize() + .padding(horizontal = 16.dp) .padding(bottom = systemBarPaddings.calculateBottomPadding() + 16.dp), - content = { - val profileScreenState = vm.collectAsState().value.profileState - OnboardTopAppBar( - modifier = Modifier.layoutId(ProfileScreenTopAppBarLayoutId), - currentStep = currentStep, - ) - TitleAndDescription( - modifier = Modifier - .layoutId(ProfileScreenTitleAndDescriptionLayoutId) - .padding( - top = 12.dp, - start = 20.dp, - end = 20.dp, - ), - titleRes = R.string.profile_title, - descriptionRes = R.string.profile_description, - ) + ) { + val profileScreenState = vm.collectAsState().value.profileState + OnboardTopAppBar(currentStep = currentStep) + TitleAndDescription( + modifier = Modifier + .padding(top = 12.dp), + titleRes = R.string.profile_title, + descriptionRes = R.string.profile_description, + ) + Box(modifier = Modifier.align(Alignment.CenterHorizontally)) { ProfilePhoto( modifier = Modifier - .layoutId(ProfileScreenProfileImageLayoutId) .padding( - // 항상 center 에 배치돼서 horizontal padding 불필요 top = 32.dp, bottom = 20.dp, ), profilePhoto = profilePhoto, resetProfilePhoto = { - profilePhoto = QuackIcon.Profile + profilePhoto = "" profilePhotoLastSelectionIndex?.let { lastSelectionIndex -> profilePhotoSelections[lastSelectionIndex] = false } @@ -262,50 +192,57 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { photoPickerVisible = true }.takeIf { vm.isImagePermissionGranted == true }, ) - // TODO(sungbin): https://github.com/duckie-team/quack-quack-android/issues/438 - QuackErrorableTextField( - modifier = Modifier - .layoutId(ProfileScreenNicknameTextFieldLayoutId) - // 패딩이 왜 2배로 들어가지?? - .padding(horizontal = 10.dp), - text = nickname, - onTextChanged = { text -> - if (text.length <= MaxNicknameLength) { - nickname = text - } - }, - placeholderText = stringResource(R.string.profile_nickname_placeholder), - isError = ProfileScreenState.errorState.contains(profileScreenState), - maxLength = MaxNicknameLength, - errorText = when (profileScreenState) { - ProfileScreenState.NicknameRuleError -> stringResource(R.string.profile_nickname_rule_error) - ProfileScreenState.NicknameDuplicateError -> stringResource(R.string.profile_nickname_duplicate_error) - ProfileScreenState.NicknameEmpty -> stringResource(R.string.profile_nickname_empty) - else -> lastErrorText // 안하면 invisible 될 때 갑자기 텍스트가 사라짐 (애니메이션 X) - }.also { errorText -> - lastErrorText = errorText - }, - keyboardActions = KeyboardActions { - keyboard?.hide() - }, - ) - QuackLargeButton( - modifier = Modifier - .layoutId(ProfileScreenNextButtonLayoutId) - .padding(horizontal = 20.dp), - text = stringResource(R.string.button_next), - type = QuackLargeButtonType.Fill, - imeAnimation = true, - enabled = profileScreenState == ProfileScreenState.Valid && nickname.isNotEmpty(), - ) { - navigateNextStep( - vm = vm, - nickname = nickname, + } + + QuackErrorableTextField( + modifier = Modifier, + text = nickname, + onTextChanged = { text -> + if (text.length <= MaxNicknameLength) { + nickname = text + } + }, + placeholderText = stringResource(R.string.profile_nickname_placeholder), + maxLength = MaxNicknameLength, + textFieldState = when (profileScreenState) { + ProfileScreenState.NicknameRuleError -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_rule_error), ) - } - }, - measurePolicy = ProfileScreenMeasurePolicy, - ) + + ProfileScreenState.NicknameDuplicateError -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_duplicate_error), + ) + + ProfileScreenState.NicknameEmpty -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_empty), + ) + + ProfileScreenState.Valid -> QuackErrorableTextFieldState.Success( + successText = stringResource(R.string.profile_nickname_valid), + ) + + else -> QuackErrorableTextFieldState.Normal + }, + keyboardActions = KeyboardActions { + keyboard?.hide() + }, + ) + + Spacer(weight = 1f) + TempFlexiblePrimaryLargeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.button_next), + enabled = profileScreenState == ProfileScreenState.Valid && + nickname.isNotEmpty(), + ) { + navigateNextStep( + vm = vm, + nickname = nickname, + ) + } + + ImeSpacer() + } // TODO(sungbin): 효율적인 애니메이션 (카메라가 로드되면서 생기는 프라임드랍 때문에 애니메이션 제거) if (photoPickerVisible) { @@ -318,7 +255,7 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { modifier = Modifier .padding(top = systemBarPaddings.calculateTopPadding()) .fillMaxSize() - .background(color = QuackColor.White.composeColor), + .background(color = QuackColor.White.value), imageUris = galleryImages, imageSelections = profilePhotoSelections, onCameraClick = { @@ -350,9 +287,6 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { } } -private val ProfilePhotoShape = SquircleShape -private val ProfilePhotoSize = DpSize(all = 80.dp) - @Composable private fun ProfilePhoto( modifier: Modifier = Modifier, @@ -360,20 +294,37 @@ private fun ProfilePhoto( resetProfilePhoto: () -> Unit, openPhotoPicker: (() -> Unit)?, ) { - QuackAnimatedContent( + AnimatedContent( modifier = modifier - .size(ProfilePhotoSize) - .clip(ProfilePhotoShape) - .quackClickable(onClick = openPhotoPicker), + .quackClickable(onClick = openPhotoPicker) + .size(DpSize(width = 80.dp, height = 80.dp)) + .clip(SquircleShape), targetState = profilePhoto, + label = "AnimatedContent", ) { photo -> - QuackImage( - src = if (photo == "") SharedIcon.ic_default_profile else photo, - size = ProfilePhotoSize, - contentScale = ContentScale.Crop, - onClick = openPhotoPicker ?: {}, // required when onLongClick is used - onLongClick = resetProfilePhoto, - ) + if ("$photo".isEmpty()) { + QuackImage( + modifier = Modifier + .quackClickable( + onClick = openPhotoPicker ?: {}, // required when onLongClick is used + onLongClick = resetProfilePhoto, + ) + .size(DpSize(width = 80.dp, height = 80.dp)), + src = SharedIcon.ic_default_profile, + contentScale = ContentScale.Crop, + ) + } else { + QuackImage( + modifier = Modifier + .quackClickable( + onClick = openPhotoPicker ?: {}, // required when onLongClick is used + onLongClick = resetProfilePhoto, + ) + .size(DpSize(width = 80.dp, height = 80.dp)), + src = photo, + contentScale = ContentScale.Crop, + ) + } } } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/2_CategoryScreen.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/2_CategoryScreen.kt index 7ddea5fce..6eca2de60 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/2_CategoryScreen.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/2_CategoryScreen.kt @@ -25,29 +25,27 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.toImmutableList import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.onboard.R -import team.duckie.app.android.feature.onboard.common.OnboardTopAppBar -import team.duckie.app.android.feature.onboard.common.TitleAndDescription -import team.duckie.app.android.feature.onboard.constant.OnboardStep -import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.asLoose import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.quack.todo.QuackGridLayout +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImage +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImageType +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.common.kotlin.fastAny import team.duckie.app.android.common.kotlin.fastFirstOrNull import team.duckie.app.android.common.kotlin.fastMapIndexedNotNull import team.duckie.app.android.common.kotlin.npe -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.component.QuackGridLayout -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackSelectableImage -import team.duckie.quackquack.ui.component.QuackSelectableImageType -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.app.android.feature.onboard.R +import team.duckie.app.android.feature.onboard.common.OnboardTopAppBar +import team.duckie.app.android.feature.onboard.common.TitleAndDescription +import team.duckie.app.android.feature.onboard.constant.OnboardStep +import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel +import team.duckie.quackquack.ui.sugar.QuackTitle2 private val currentStep = OnboardStep.Category @@ -102,16 +100,17 @@ private val CategoryScreenMeasurePolicy = MeasurePolicy { measurables, constrain internal fun CategoryScreen(vm: OnboardViewModel = activityViewModel()) { val onboardState by vm.collectAsState() - val categoriesSelectedIndex = remember(onboardState.categories, onboardState.selectedCategories) { - mutableStateListOf( - elements = Array( - size = onboardState.categories.size, - init = { index -> - onboardState.selectedCategories.contains(onboardState.categories[index]) - }, - ), - ) - } + val categoriesSelectedIndex = + remember(onboardState.categories, onboardState.selectedCategories) { + mutableStateListOf( + elements = Array( + size = onboardState.categories.size, + init = { index -> + onboardState.selectedCategories.contains(onboardState.categories[index]) + }, + ), + ) + } Layout( modifier = Modifier @@ -158,35 +157,26 @@ internal fun CategoryScreen(vm: OnboardViewModel = activityViewModel()) { ) } } - QuackAnimatedVisibility( + TempFlexiblePrimaryLargeButton( modifier = Modifier .layoutId(CategoryScreenNextButtonLayoutId) - .padding(horizontal = 20.dp) - .fillMaxWidth(), - visible = categoriesSelectedIndex.fastAny { it }, + .fillMaxWidth() + .padding(horizontal = 20.dp), + text = stringResource(R.string.button_next), + enabled = categoriesSelectedIndex.fastAny { it }, ) { - QuackLargeButton( - type = QuackLargeButtonType.Fill, - enabled = true, - text = stringResource(R.string.button_next), - ) { - vm.updateUserSelectCategories( - categories = categoriesSelectedIndex.fastMapIndexedNotNull { index, selected -> - onboardState.categories[index].takeIf { selected } - }, - ) - vm.navigateStep(currentStep + 1) - } + vm.updateUserSelectCategories( + categories = categoriesSelectedIndex.fastMapIndexedNotNull { index, selected -> + onboardState.categories[index].takeIf { selected } + }, + ) + vm.navigateStep(currentStep + 1) } }, measurePolicy = CategoryScreenMeasurePolicy, ) } -// FIXME(sungbin): 디자인상 100.dp 가 맞는데, 100 을 주면 디바이스 너비에 압축됨 -private val CategoryImageSize = DpSize(all = 80.dp) -private val CategoryItemShape = RoundedCornerShape(size = 12.dp) - @Composable private fun CategoryItem( imageUrl: String, @@ -203,12 +193,13 @@ private fun CategoryItem( ) { QuackSelectableImage( src = imageUrl, - size = CategoryImageSize, - shape = CategoryItemShape, + size = DpSize(width = 80.dp, height = 80.dp), + shape = RoundedCornerShape(size = 12.dp), selectableType = QuackSelectableImageType.CheckOverlay, isSelected = isSelected, onClick = onClick, ) + QuackTitle2(text = name) } } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/3_TagScreen.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/3_TagScreen.kt index 1bb42eb3f..29b1d0b3b 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/3_TagScreen.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/3_TagScreen.kt @@ -5,7 +5,11 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@file:OptIn( + ExperimentalMaterialApi::class, + ExperimentalComposeUiApi::class, + ExperimentalQuackQuackApi::class, +) @file:Suppress( "ConstPropertyName", "PrivatePropertyName", @@ -42,7 +46,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.google.accompanist.flowlayout.FlowRow @@ -50,10 +53,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.HideKeyboardWhenBottomSheetHidden import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.asLoose import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.compose.ui.domain.DuckieTagAddBottomSheet +import team.duckie.app.android.common.compose.ui.quack.todo.QuackCircleTag +import team.duckie.app.android.common.compose.ui.quack.todo.QuackOutLinedSingeLazyRowTag +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.common.kotlin.AllowMagicNumber import team.duckie.app.android.common.kotlin.fastAny import team.duckie.app.android.common.kotlin.fastFirstOrNull @@ -68,15 +75,12 @@ import team.duckie.app.android.feature.onboard.common.TitleAndDescription import team.duckie.app.android.feature.onboard.constant.OnboardStep import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.feature.onboard.viewmodel.state.OnboardState -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackCircleTag -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackSingeLazyRowTag -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackTitle2 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private val currentStep = OnboardStep.Tag @@ -124,7 +128,6 @@ private val TagScreenMeasurePolicy = MeasurePolicy { measurables, constraints -> @Composable internal fun TagScreen(vm: OnboardViewModel = activityViewModel()) { - val keyboard = LocalSoftwareKeyboardController.current val coroutineScope = rememberCoroutineScope() var isLoadingToFinish by remember { mutableStateOf(false) } @@ -137,14 +140,7 @@ internal fun TagScreen(vm: OnboardViewModel = activityViewModel()) { skipHalfExpanded = true, ) - LaunchedEffect(sheetState) { - val sheetStateFlow = snapshotFlow { sheetState.currentValue } - sheetStateFlow.collect { state -> - if (state == ModalBottomSheetValue.Hidden) { - keyboard?.hide() - } - } - } + HideKeyboardWhenBottomSheetHidden(sheetState) BackHandler(sheetState.isVisible) { coroutineScope.launch { @@ -182,14 +178,17 @@ internal fun TagScreen(vm: OnboardViewModel = activityViewModel()) { addedTags.remove(addedTags[index]) }, ) - QuackLargeButton( + + // TODO(riflockle7): 문제 있으므로 꽥꽥 이슈 해결할 때까지 주석 제거하지 않음 + // type = QuackLargeButtonType.Fill, + // isLoading = isLoadingToFinish, + TempFlexiblePrimaryLargeButton( modifier = Modifier .layoutId(TagScreenQuackLargeButtonLayoutId) + .fillMaxWidth() .padding(horizontal = 20.dp), text = stringResource(id = R.string.button_start_duckie), - type = QuackLargeButtonType.Fill, enabled = true, - isLoading = isLoadingToFinish, ) { updateUserAndFinishOnboard( coroutineScope = coroutineScope, @@ -308,31 +307,29 @@ private fun TagSelection( QuackCircleTag( text = tag.name, isSelected = false, - trailingIcon = QuackIcon.Close, ) { requestRemoveAddedTag(index) } } } } - QuackHeadLine2( - modifier = Modifier.padding( - top = if (addedTags.isNotEmpty()) 0.dp else 4.dp, - start = 10.dp, - ), + QuackText( + modifier = Modifier + .quackClickable( + onClick = { + coroutineScope.launch { + sheetState.show() + } + }, + ) + .padding( + top = if (addedTags.isNotEmpty()) 0.dp else 8.dp, + start = 20.dp, + end = 10.dp, + bottom = 8.dp, + ), text = stringResource(R.string.tag_add_manual), - padding = PaddingValues( - top = if (addedTags.isNotEmpty()) 0.dp else 4.dp, - start = 10.dp, - end = 10.dp, - bottom = 8.dp, - ), - color = QuackColor.DuckieOrange, - onClick = { - coroutineScope.launch { - sheetState.show() - } - }, + typography = QuackTypography.HeadLine2.change(color = QuackColor.DuckieOrange), ) } @AllowMagicNumber(because = "(34 - 8).dp") @@ -344,11 +341,11 @@ private fun TagSelection( verticalArrangement = Arrangement.spacedBy(16.dp), ) { onboardState.selectedCategories.fastForEachIndexed { categoryIndex, category -> - QuackSingeLazyRowTag( + QuackOutLinedSingeLazyRowTag( title = stringResource(R.string.tag_hottest_tag, category.name), items = hottestTags[categoryIndex], itemSelections = hottestTagSelections[categoryIndex], - tagType = QuackTagType.Circle(), + trailingIcon = null, contentPadding = PaddingValues(horizontal = 20.dp), onClick = { tagIndex -> hottestTagSelections[categoryIndex][tagIndex] = diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/viewmodel/OnboardViewModel.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/viewmodel/OnboardViewModel.kt index f9724af35..56e4cf676 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/viewmodel/OnboardViewModel.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/viewmodel/OnboardViewModel.kt @@ -35,6 +35,7 @@ import org.orbitmvi.orbit.viewmodel.container import team.duckie.app.android.common.android.permission.PermissionCompat import team.duckie.app.android.common.android.savedstate.SaveableMutableStateFlow import team.duckie.app.android.common.android.viewmodel.context +import team.duckie.app.android.common.kotlin.exception.isKakaoCancelled import team.duckie.app.android.common.kotlin.seconds import team.duckie.app.android.domain.auth.usecase.AttachAccessTokenToHeaderUseCase import team.duckie.app.android.domain.auth.usecase.JoinUseCase @@ -182,6 +183,10 @@ internal class OnboardViewModel @AssistedInject constructor( } } + fun nicknameChecking() = intent { + reduce { state.copy(profileState = ProfileScreenState.Checking) } + } + /** 닉네임을 체크한다. */ fun checkNickname(nickname: String) { val isNicknameRuleError = checkNicknameRule(nickname) @@ -267,7 +272,7 @@ internal class OnboardViewModel @AssistedInject constructor( /* ----- Api ----- */ - suspend fun getKakaoAccessTokenAndJoin() = intent { + fun getKakaoAccessTokenAndJoin() = intent { getKakaoAccessTokenUseCase() .onSuccess { token -> postSideEffect(OnboardSideEffect.DelegateJoin(token)) @@ -275,7 +280,7 @@ internal class OnboardViewModel @AssistedInject constructor( .attachExceptionHandling() } - suspend fun join(kakaoAccessToken: String) = intent { + fun join(kakaoAccessToken: String) = intent { joinUseCase(kakaoAccessToken) .onSuccess { response -> reduce { @@ -371,7 +376,10 @@ internal class OnboardViewModel @AssistedInject constructor( additinal: suspend (exception: Throwable) -> Unit = {}, ) = intent { onFailure { exception -> - postSideEffect(OnboardSideEffect.ReportError(exception)) + when { + exception.isKakaoCancelled -> return@onFailure // this is not error + else -> postSideEffect(OnboardSideEffect.ReportError(exception)) + } additinal(exception) } } diff --git a/feature/onboard/src/main/res/values/strings.xml b/feature/onboard/src/main/res/values/strings.xml index e2ae0d65f..b74b0361a 100644 --- a/feature/onboard/src/main/res/values/strings.xml +++ b/feature/onboard/src/main/res/values/strings.xml @@ -29,6 +29,7 @@ 문자, 숫자, 밑줄, 마침표만 사용할 수 있어요. 이미 있는 이름이에요 :( 닉네임을 입력해주세요. + 사용 가능한 닉네임이에요! 어떤 분야를 좋아하나요? 1개 이상 골라보세요.\n취향에 맞춰 피드를 추천해드려요 :) @@ -37,7 +38,7 @@ 태그 할수록 더키의 추천이 정확해져요! 추가한 태그 + 직접 태그 추가하기 - 태그 입력하기 + 태그 입력하기 (최대 10자) %s 분야 인기 태그 이미 추가한 태그예요. 태그 생성에 실패했어요. 실패한 태그: %s diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index 5f22a464a..e4d272466 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -31,7 +31,6 @@ dependencies { libs.ktx.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold libs.compose.lifecycle.runtime, - libs.quack.ui.components, libs.quack.v2.ui, libs.kotlin.collections.immutable, libs.paging.runtime, diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/ProfileActivity.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/ProfileActivity.kt index 1c713d17b..9066b93b9 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/ProfileActivity.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/ProfileActivity.kt @@ -55,7 +55,7 @@ class ProfileActivity : BaseActivity() { private val viewModel: ProfileViewModel by viewModels() @Inject - lateinit var createProblemNavigator: CreateProblemNavigator + lateinit var createExamNavigator: CreateProblemNavigator @Inject lateinit var notificationNavigator: NotificationNavigator @@ -170,7 +170,7 @@ class ProfileActivity : BaseActivity() { } ProfileSideEffect.NavigateToMakeExam -> { - createProblemNavigator.navigateFrom(this@ProfileActivity) + createExamNavigator.navigateFrom(this@ProfileActivity) } is ProfileSideEffect.NavigateToExamDetail -> { diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/component/EditTopAppBar.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/component/EditTopAppBar.kt index c2dd032aa..53addbedc 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/component/EditTopAppBar.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/component/EditTopAppBar.kt @@ -23,7 +23,7 @@ import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackHeadLine2 @@ -47,9 +47,9 @@ internal fun EditTopAppBar( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - QuackImage( + QuackIcon( modifier = Modifier.quackClickable(onClick = onBackPressed), - src = QuackIcon.Outlined.ArrowBack, + icon = QuackIcon.Outlined.ArrowBack, ) QuackHeadLine2(text = title) diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/MyProfileScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/MyProfileScreen.kt index b2d67e090..370a3c823 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/MyProfileScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/MyProfileScreen.kt @@ -4,6 +4,7 @@ * Licensed under the MIT. * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ + @file:OptIn(ExperimentalQuackQuackApi::class) package team.duckie.app.android.feature.profile.screen @@ -12,7 +13,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -23,7 +24,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import team.duckie.app.android.common.compose.ui.BackPressedHeadLineTopAppBar import team.duckie.app.android.common.compose.ui.DuckTestCoverItem -import team.duckie.app.android.common.compose.ui.icon.v1.Notice +import team.duckie.app.android.common.compose.ui.temp.TempFlexibleSecondaryLargeButton import team.duckie.app.android.common.kotlin.FriendsType import team.duckie.app.android.domain.exam.model.ProfileExam import team.duckie.app.android.domain.user.model.UserProfile @@ -38,13 +39,11 @@ import team.duckie.app.android.feature.profile.viewmodel.state.mapper.toUiModel import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.Create +import team.duckie.quackquack.material.icon.quackicon.outlined.Notice import team.duckie.quackquack.material.icon.quackicon.outlined.Setting import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi -import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon @Suppress("UnusedPrivateMember") // 시험 생성하기를 추후에 다시 활용하기 위함 @Composable @@ -67,17 +66,13 @@ fun MyProfileScreen( @Composable fun BackPressedHeadLineTopBarInternal() { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - QuackImage( - modifier = Modifier - .size(24.dp, 24.dp) - .quackClickable(onClick = onClickNotification), - src = QuackV1Icon.Companion.Notice, + QuackIcon( + modifier = Modifier.quackClickable(onClick = onClickNotification), + icon = QuackIcon.Outlined.Notice, ) - QuackImage( - modifier = Modifier - .size(24.dp, 24.dp) - .quackClickable(onClick = onClickSetting), - src = QuackIcon.Outlined.Setting, + QuackIcon( + modifier = Modifier.quackClickable(onClick = onClickSetting), + icon = QuackIcon.Outlined.Setting, ) } } @@ -125,8 +120,8 @@ fun MyProfileScreen( title = stringResource(id = R.string.my_favorite_tag), tags = tags, emptySection = { - QuackLargeButton( - type = QuackLargeButtonType.Compact, + TempFlexibleSecondaryLargeButton( + modifier = Modifier.fillMaxWidth(), text = stringResource(id = R.string.add_favorite_tag), onClick = onClickEditTag, ) @@ -150,8 +145,8 @@ fun MyProfileScreen( EmptyText(message = stringResource(id = R.string.not_yet_submit_exam)) // TODO(limsaehyun): 시험 생성하기가 가능한 스펙에서 활용 // Spacer(modifier = Modifier.padding(8.dp)) -// QuackLargeButton( -// type = QuackLargeButtonType.Compact, +// QuackButton( +// style = QuackButtonStyle.SecondaryLarge, // text = stringResource(id = R.string.make_exam), // onClick = onClickMakeExam, // ) diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/OtherProfileScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/OtherProfileScreen.kt index 338870ff2..c35e65b51 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/OtherProfileScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/OtherProfileScreen.kt @@ -11,7 +11,8 @@ package team.duckie.app.android.feature.profile.screen import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.rememberModalBottomSheetState @@ -22,7 +23,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.persistentListOf @@ -48,7 +48,7 @@ import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.Create import team.duckie.quackquack.material.icon.quackicon.outlined.More import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon @Composable internal fun OtherProfileScreen( @@ -102,6 +102,9 @@ internal fun OtherProfileScreen( isLoading = state.isLoading, editSection = { FollowSection( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), enabled = state.follow, onClick = viewModel::clickFollow, ) @@ -112,9 +115,8 @@ internal fun OtherProfileScreen( isLoading = state.isLoading, onBackPressed = viewModel::clickBackPress, trailingContent = { - QuackImage( + QuackIcon( modifier = Modifier - .size(DpSize(24.dp, 24.dp)) .quackClickable( onClick = { viewModel.updateBottomSheetDialogType(DuckieSelectableType.Ignore) @@ -123,7 +125,7 @@ internal fun OtherProfileScreen( } }, ), - src = QuackIcon.Outlined.More, + icon = QuackIcon.Outlined.More, ) }, ) diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/ProfileScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/ProfileScreen.kt index 8a50191e3..6a9462b99 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/ProfileScreen.kt @@ -65,16 +65,13 @@ fun ProfileScreen( Column( modifier = Modifier .verticalScroll(scrollState) - .padding( - horizontal = 16.dp, - vertical = 24.dp, - ), + .padding(vertical = 24.dp), ) { with(userProfile) { ProfileSection( userId = user?.id ?: 0, profile = user?.profileImageUrl ?: "", - duckPower = user?.duckPower?.tier ?: "", + duckPower = user?.duckPower?.tier ?: "0덕", follower = followerCount, following = followingCount, introduce = user?.introduction diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/edit/ProfileEditScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/edit/ProfileEditScreen.kt index 341fd257e..688a739ca 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/edit/ProfileEditScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/edit/ProfileEditScreen.kt @@ -5,7 +5,11 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(ExperimentalComposeUiApi::class) +@file:OptIn( + ExperimentalComposeUiApi::class, + ExperimentalDesignToken::class, + ExperimentalQuackQuackApi::class, +) package team.duckie.app.android.feature.profile.screen.edit @@ -14,6 +18,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.launch import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,6 +30,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -43,7 +51,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import team.duckie.app.android.common.compose.ui.PhotoPicker import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.quack.todo.QuackErrorableTextField +import team.duckie.app.android.common.compose.ui.quack.todo.QuackErrorableTextFieldState import team.duckie.app.android.common.compose.ui.skeleton +import team.duckie.app.android.common.compose.util.addFocusCleaner import team.duckie.app.android.feature.profile.R import team.duckie.app.android.feature.profile.component.EditTopAppBar import team.duckie.app.android.feature.profile.component.GrayBorderButton @@ -54,10 +65,13 @@ import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.shape.SquircleShape import team.duckie.quackquack.ui.QuackImage import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.component.QuackErrorableTextField -import team.duckie.quackquack.ui.component.QuackReviewTextArea +import team.duckie.quackquack.ui.QuackTextArea +import team.duckie.quackquack.ui.QuackTextAreaStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.textAreaCounter +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi -private const val MaxNicknameLength = 12 +private const val MaxNicknameLength = 10 private const val MaxIntroductionLength = 60 @Composable @@ -67,6 +81,10 @@ internal fun ProfileEditScreen( val context = LocalContext.current.applicationContext val state by vm.container.stateFlow.collectAsStateWithLifecycle() + // keyboard focused + val interactionSource = remember { MutableInteractionSource() } + val introduceTextFieldFocused = interactionSource.collectIsFocusedAsState().value + val galleryState = remember(state) { state.galleryState } val keyboardController = LocalSoftwareKeyboardController.current val coroutineScope = rememberCoroutineScope() @@ -118,6 +136,7 @@ internal fun ProfileEditScreen( modifier = Modifier .fillMaxSize() .background(QuackColor.White.value) + .addFocusCleaner() .navigationBarsPadding() .systemBarsPadding(), ) { @@ -136,14 +155,14 @@ internal fun ProfileEditScreen( Spacer(space = 40.dp) ProfileEditSection( profile = state.profile, - onClickEditProfile = vm::clickEditProfile, + onClickEditProfile = vm::loadGalleryImages, ) Spacer(space = 40.dp) QuackText( text = stringResource(R.string.nickname), typography = QuackTypography.Body1.change(color = QuackColor.Gray1), ) - // TODO(riflockle7): quack v1 -> quack v2 + Spacer(space = 16.dp) QuackErrorableTextField( modifier = Modifier.skeleton(state.isLoading), text = state.nickname, @@ -153,12 +172,21 @@ internal fun ProfileEditScreen( } }, placeholderText = stringResource(R.string.profile_nickname_placeholder), - isError = state.nicknameState.isInValid(), maxLength = MaxNicknameLength, - errorText = when (state.nicknameState) { - NicknameState.NicknameRuleError -> stringResource(R.string.profile_nickname_rule_error) - NicknameState.NicknameDuplicateError -> stringResource(R.string.profile_nickname_duplicate_error) - else -> "" + textFieldState = when (state.nicknameState) { + NicknameState.NicknameRuleError -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_rule_error), + ) + + NicknameState.NicknameDuplicateError -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_duplicate_error), + ) + + NicknameState.Valid -> QuackErrorableTextFieldState.Success( + successText = stringResource(R.string.profile_nickname_valid), + ) + + else -> QuackErrorableTextFieldState.Normal }, keyboardActions = KeyboardActions { keyboardController?.hide() @@ -170,18 +198,28 @@ internal fun ProfileEditScreen( typography = QuackTypography.Body1.change(color = QuackColor.Gray1), ) Spacer(space = 8.dp) - // TODO(riflockle7): quack v1 -> quack v2 - QuackReviewTextArea( - // TODO(evergreenTree97) 배포 후 글자 수 제한있는 텍스트필드 구현 - modifier = Modifier.skeleton(state.isLoading), - text = state.introduce, - onTextChanged = { + QuackTextArea( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = if (introduceTextFieldFocused) { + QuackColor.DuckieOrange.value + } else { + QuackColor.Gray3.value + }, + shape = RoundedCornerShape(8.dp), + ) + .textAreaCounter(maxLength = 60), + style = QuackTextAreaStyle.Default, + value = state.introduce, + onValueChange = { if (it.length <= MaxIntroductionLength) { vm.inputIntroduce(it) } }, - focused = state.introduceFocused, placeholderText = stringResource(id = R.string.please_input_introduce), + interactionSource = interactionSource, ) } } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ButtonSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ButtonSection.kt index b367ecf46..56314c7b1 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ButtonSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ButtonSection.kt @@ -7,29 +7,30 @@ package team.duckie.app.android.feature.profile.screen.section +import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import team.duckie.app.android.feature.profile.R -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText @Composable internal fun FollowSection( + modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit, ) { EditButton( - modifier = Modifier.fillMaxWidth(), + modifier = modifier, text = if (enabled) { stringResource(id = R.string.follow) } else { @@ -48,39 +49,39 @@ fun EditButton( enabled: Boolean = false, plainTextColor: QuackColor = QuackColor.Gray1, ) { - QuackSurface( - modifier = modifier.fillMaxWidth(), - backgroundColor = if (enabled) { - QuackColor.White - } else { - QuackColor.Gray4 - }, - border = if (enabled) { - QuackBorder( + QuackText( + modifier = modifier + .clip(RoundedCornerShape(size = 8.dp)) + .quackClickable(onClick = onClick) + .background( + if (enabled) { + QuackColor.White.toBrush() + } else { + QuackColor.Gray4.toBrush() + }, + ) + .border( width = 1.dp, - color = QuackColor.DuckieOrange, + brush = if (enabled) { + QuackColor.DuckieOrange.toBrush() + } else { + QuackColor.Unspecified.toBrush() + }, + shape = RoundedCornerShape(size = 8.dp), ) - } else { - null - }, - shape = RoundedCornerShape(size = 8.dp), - onClick = onClick, - ) { - QuackText( - modifier = Modifier.padding( + .padding( vertical = 8.dp, horizontal = 12.dp, ), - text = text, - style = QuackTextStyle.Body1.change( - color = if (enabled) { - QuackColor.DuckieOrange - } else { - plainTextColor - }, - textAlign = TextAlign.Center, - ), - singleLine = true, - ) - } + text = text, + typography = QuackTypography.Body1.change( + color = if (enabled) { + QuackColor.DuckieOrange + } else { + plainTextColor + }, + textAlign = TextAlign.Center, + ), + singleLine = true, + ) } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/EditSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/EditSection.kt index b1292bf8c..dfe32bbaf 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/EditSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/EditSection.kt @@ -10,6 +10,7 @@ package team.duckie.app.android.feature.profile.screen.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,7 +18,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.feature.profile.R -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor @Composable internal fun EditSection( @@ -25,7 +26,9 @@ internal fun EditSection( onClickEditTag: () -> Unit, ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ExamSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ExamSection.kt index 3743a9f68..316a317aa 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ExamSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ExamSection.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -30,7 +31,7 @@ import team.duckie.app.android.feature.profile.R import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackTitle2 @@ -47,7 +48,9 @@ fun ExamSection( ) { Column(modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -55,11 +58,11 @@ fun ExamSection( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - QuackImage( + QuackIcon( modifier = Modifier .size(DpSize(24.dp, 24.dp)) .skeleton(isLoading), - src = icon, + icon = icon, ) QuackTitle2( modifier = Modifier.skeleton(isLoading), @@ -81,6 +84,9 @@ fun ExamSection( LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + item { + Spacer(space = 16.dp) + } items(exams) { item -> DuckExamSmallCover( isLoading = isLoading, diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/FavoriteSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/FavoriteSection.kt index 07bde2d58..322aa8526 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/FavoriteSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/FavoriteSection.kt @@ -9,37 +9,44 @@ package team.duckie.app.android.feature.profile.screen.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList +import team.duckie.app.android.common.compose.ui.quack.todo.QuackOutLinedSingeLazyRowTag import team.duckie.app.android.common.compose.ui.skeleton import team.duckie.app.android.domain.tag.model.Tag -import team.duckie.quackquack.ui.component.QuackSingeLazyRowTag -import team.duckie.quackquack.ui.component.QuackTagType import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun FavoriteTagSection( isLoading: Boolean, title: String, - emptySection: @Composable () -> Unit, + emptySection: @Composable ColumnScope.() -> Unit, tags: ImmutableList, onClickTag: (String) -> Unit, ) { val tagList = remember(tags) { tags.map { it.name }.toList() } - Column(verticalArrangement = Arrangement.spacedBy(13.5.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(13.5.dp), + ) { QuackTitle2(text = title) if (tags.isEmpty()) { emptySection() } else { // TODO(riflockle7): quack v2 에서 대체할 수 있는 내용 찾기 - QuackSingeLazyRowTag( + QuackOutLinedSingeLazyRowTag( modifier = Modifier.skeleton(isLoading), items = tagList, - tagType = QuackTagType.Circle(), + trailingIcon = null, onClick = { index -> onClickTag(tagList[index]) }, ) } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ProfileSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ProfileSection.kt index 8722cbfa4..54afc228c 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ProfileSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ProfileSection.kt @@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,7 +40,11 @@ internal fun ProfileSection( isLoading: Boolean, onClickFriend: (FriendsType, Int) -> Unit, ) { - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/viewall/ViewAllScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/viewall/ViewAllScreen.kt index 8a6f4bb70..3c6dca280 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/viewall/ViewAllScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/viewall/ViewAllScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -21,6 +22,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems +import team.duckie.app.android.common.compose.getUniqueKey import team.duckie.app.android.common.compose.itemsPagingKey import team.duckie.app.android.common.compose.ui.BackPressedHeadLine2TopAppBar import team.duckie.app.android.common.compose.ui.DuckExamSmallCoverForColumn @@ -33,6 +35,21 @@ import team.duckie.app.android.feature.profile.viewmodel.state.ExamType import team.duckie.app.android.feature.profile.viewmodel.state.mapper.toUiModel import team.duckie.quackquack.material.QuackColor +private const val GRID_COUNT: Int = 2 + +/** + * [GRID_COUNT]만큼 item 을 생성하여 [Arrangement.spacedBy]로 스크롤에 여백을 만듭니다. + */ +private fun LazyGridScope.spaceItem( + maxIndex: Int, + content: @Composable () -> Unit = {}, +) { + val count = maxIndex % GRID_COUNT + 1 + repeat(count) { + item { content() } + } +} + @Composable fun ViewAllScreen( examType: ExamType, @@ -52,26 +69,31 @@ fun ViewAllScreen( title = getViewAllTitle(examType = examType), onBackPressed = onBackPressed, ) - Spacer(space = 20.dp) LazyVerticalGrid( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(GRID_COUNT), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + repeat(GRID_COUNT) { + item { + Spacer(space = 20.dp) + } + } when (examType) { ExamType.Heart, ExamType.Created -> { items( count = profileExams.itemCount, key = itemsPagingKey( items = profileExams, - key = { profileExams[it]?.id }, + key = { profileExams[it]?.id?.getUniqueKey(it) }, ), ) { index -> profileExams[index]?.let { item -> val duckTestCoverItem = item.toUiModel() DuckExamSmallCoverForColumn( + modifier = Modifier.padding(bottom = 40.dp), duckTestCoverItem = duckTestCoverItem, onItemClick = { onItemClick(duckTestCoverItem) }, isLoading = profileExamInstances.loadState.append == LoadState.Loading, @@ -79,6 +101,7 @@ fun ViewAllScreen( ) } } + spaceItem(profileExams.itemCount) } ExamType.Solved -> { @@ -86,7 +109,7 @@ fun ViewAllScreen( count = profileExamInstances.itemCount, key = itemsPagingKey( items = profileExamInstances, - key = { profileExamInstances[it]?.id }, + key = { profileExams[it]?.id?.getUniqueKey(it) }, ), ) { index -> profileExamInstances[index]?.let { item -> @@ -98,7 +121,11 @@ fun ViewAllScreen( onMoreClick = onMoreClick, // 추후 신고하기 구현 필요 ) } + if (index == profileExams.itemCount) { + Spacer(space = 40.dp) + } } + spaceItem(profileExamInstances.itemCount) } } } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileEditViewModel.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileEditViewModel.kt index cf0d26c87..ac124c84e 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileEditViewModel.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileEditViewModel.kt @@ -15,7 +15,9 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.launch import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent @@ -111,10 +113,6 @@ class ProfileEditViewModel @Inject constructor( } } - fun clickEditProfile() = intent { - loadGalleryImages() - } - private fun initUser() = intent { updateLoading(true) getUserUseCase(state.userId).onSuccess { user -> @@ -135,17 +133,19 @@ class ProfileEditViewModel @Inject constructor( } } - private fun loadGalleryImages() = intent { - loadGalleryImagesUseCase().onSuccess { images -> - changeGalleryState( - galleryState = state.galleryState.copy( - images = persistentListOf(*images.toTypedArray()), - imagesSelections = images.fastMap { false }.toImmutableList(), - ), - ) - changePhotoPickerVisible(true) - }.onFailure { - postSideEffect(ProfileEditSideEffect.ReportError(it)) + fun loadGalleryImages() = intent { + viewModelScope.launch(Dispatchers.IO) { + loadGalleryImagesUseCase().onSuccess { images -> + changeGalleryState( + galleryState = state.galleryState.copy( + images = persistentListOf(*images.toTypedArray()), + imagesSelections = images.fastMap { false }.toImmutableList(), + ), + ) + changePhotoPickerVisible(true) + }.onFailure { + postSideEffect(ProfileEditSideEffect.ReportError(it)) + } } } @@ -200,8 +200,6 @@ class ProfileEditViewModel @Inject constructor( } fun clickEditComplete(applicationContext: Context?) = intent { - updateLoading(true) - getUploadableFileUrl( state.profile.toString(), applicationContext, @@ -217,14 +215,11 @@ class ProfileEditViewModel @Inject constructor( nickname = state.nickname, introduction = state.introduce, ).onSuccess { - updateLoading(false) postSideEffect(ProfileEditSideEffect.NavigateBack) }.onFailure { - updateLoading(false) postSideEffect(ProfileEditSideEffect.ReportError(it)) } }.onFailure { - updateLoading(false) postSideEffect(ProfileEditSideEffect.ReportError(it)) } } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileViewModel.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileViewModel.kt index e35a2b60c..3441b238a 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.SimpleSyntax import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce @@ -87,7 +88,7 @@ internal class ProfileViewModel @Inject constructor( fun init() = intent { val userId = savedStateHandle.getStateFlow(Extras.UserId, 0).value - startLoading() + updateLoading(true) val job = viewModelScope.launch { getMeUseCase() @@ -97,7 +98,7 @@ internal class ProfileViewModel @Inject constructor( reduce { state.copy(step = ProfileStep.Error) } postSideEffect(ProfileSideEffect.ReportError(it)) }.also { - stopLoading() + updateLoading(false) } }.apply { join() } @@ -118,7 +119,7 @@ internal class ProfileViewModel @Inject constructor( reduce { state.copy(step = ProfileStep.Error) } postSideEffect(ProfileSideEffect.ReportError(it)) }.also { - stopLoading() + updateLoading(false) } } } @@ -156,7 +157,6 @@ internal class ProfileViewModel @Inject constructor( } fun getUserProfile() = intent { - startLoading() viewModelScope.launch { fetchUserProfileUseCase(state.userId) .onSuccess { profile -> @@ -170,8 +170,6 @@ internal class ProfileViewModel @Inject constructor( .onFailure { reduce { state.copy(step = ProfileStep.Error) } postSideEffect(ProfileSideEffect.ReportError(it)) - }.also { - stopLoading() } } } @@ -241,15 +239,9 @@ internal class ProfileViewModel @Inject constructor( postSideEffect(ProfileSideEffect.NavigateToMakeExam) } - private fun startLoading() = intent { - reduce { - state.copy(isLoading = true) - } - } - - private fun stopLoading() = intent { + private suspend fun SimpleSyntax.updateLoading(isLoading: Boolean) { reduce { - state.copy(isLoading = false) + state.copy(isLoading = isLoading) } } } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/state/ProfileEditState.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/state/ProfileEditState.kt index 4084f905c..6b84e6ae0 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/state/ProfileEditState.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/state/ProfileEditState.kt @@ -19,7 +19,6 @@ data class ProfileEditState( val nicknameState: NicknameState = NicknameState.Checking, val galleryState: GalleryState = GalleryState(), val introduce: String = "", - val introduceFocused: Boolean = false, val userId: Int = 0, ) diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml index 06a1d2778..657bc2edb 100644 --- a/feature/profile/src/main/res/values/strings.xml +++ b/feature/profile/src/main/res/values/strings.xml @@ -38,6 +38,7 @@ 이름에는 문자, 숫자, 밑줄, 마침표만 사용할 수 있어요. 이미 있는 이름이에요 전체보기 + 사용 가능한 닉네임이에요! 닉네임 소개 diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index 3b189d08f..ec18da48a 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -28,10 +28,13 @@ dependencies { projects.common.compose, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, libs.compose.lifecycle.runtime, + libs.compose.ui.material, libs.firebase.crashlytics, libs.paging.runtime, libs.paging.compose, + libs.quack.v2.ui, + libs.quack.v2.ui.plugin.interceptor.textfield, + libs.kotlin.collections.immutable, ) } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchActivity.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchActivity.kt index aeb7a2eee..37322b074 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchActivity.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchActivity.kt @@ -5,37 +5,74 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalMaterialApi::class) +// TODO(limsaehyun): The function onCreate is too long (127). +// The maximum length is 100. [LongMethod] 대응 필요 +@file:Suppress("LongMethod") + package team.duckie.app.android.feature.search.screen import android.os.Bundle +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase import dagger.hilt.android.AndroidEntryPoint +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.android.deeplink.DynamicLinkHelper import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.common.android.ui.finishWithAnimation +import team.duckie.app.android.common.android.ui.popStringExtra +import team.duckie.app.android.common.compose.collectAndHandleState +import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.DuckieCircularProgressIndicator +import team.duckie.app.android.common.compose.ui.ErrorScreen +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.constant.SharedIcon +import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableBottomSheetDialog +import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableType +import team.duckie.app.android.common.compose.ui.dialog.ReportDialog +import team.duckie.app.android.common.compose.ui.quack.QuackNoUnderlineTextField +import team.duckie.app.android.common.compose.util.addFocusCleaner +import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.feature.search.R import team.duckie.app.android.feature.search.constants.SearchResultStep import team.duckie.app.android.feature.search.constants.SearchStep @@ -43,20 +80,15 @@ import team.duckie.app.android.feature.search.viewmodel.SearchViewModel import team.duckie.app.android.feature.search.viewmodel.sideeffect.SearchSideEffect import team.duckie.app.android.navigator.feature.detail.DetailNavigator import team.duckie.app.android.navigator.feature.profile.ProfileNavigator -import team.duckie.app.android.common.compose.ui.DuckieCircularProgressIndicator -import team.duckie.app.android.common.compose.ui.ErrorScreen -import team.duckie.app.android.common.compose.ui.constant.SharedIcon -import team.duckie.app.android.common.compose.ui.quack.QuackNoUnderlineTextField -import team.duckie.app.android.common.compose.collectAndHandleState -import team.duckie.app.android.common.android.ui.finishWithAnimation -import team.duckie.app.android.common.android.ui.popStringExtra -import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.theme.QuackTheme -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.material.theme.QuackTheme +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.plugin.interceptor.textfield.QuackTextFieldFontFamilyRemovalPlugin +import team.duckie.quackquack.ui.plugin.rememberQuackPlugins import javax.inject.Inject internal val SearchHorizontalPadding = PaddingValues(horizontal = 16.dp) @@ -84,88 +116,152 @@ class SearchActivity : BaseActivity() { setContent { val state = vm.collectAsState().value + val focusRequester = remember { FocusRequester() } + val bottomSheetDialogState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val coroutineScope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + val searchText by vm.searchText.collectAsStateWithLifecycle() + val focusManager = LocalFocusManager.current vm.searchUsers.collectAndHandleState(handleLoadStates = vm::checkError) - vm.searchExams.collectAndHandleState(handleLoadStates = vm::checkError) + val searchExams = + vm.searchExams.collectAndHandleState(handleLoadStates = vm::checkError) LaunchedEffect(key1 = vm) { vm.container.sideEffectFlow - .onEach(::handleSideEffect) + .onEach { + handleSideEffect(it, searchExams) + } .launchIn(this) } - QuackTheme { - Box( + LaunchedEffect(key1 = state.searchAutoFocusing) { + if (state.searchAutoFocusing) { + focusRequester.requestFocus() + } + } + + QuackTheme( + plugins = rememberQuackPlugins { + +QuackTextFieldFontFamilyRemovalPlugin + }, + ) { + ReportDialog( + visible = state.reportDialogVisible, + onClick = { vm.updateReportDialogVisible(false) }, + onDismissRequest = { vm.updateReportDialogVisible(false) }, + ) + DuckieSelectableBottomSheetDialog( modifier = Modifier .fillMaxSize() - .systemBarsPadding(), - contentAlignment = Alignment.Center, + .systemBarsPadding() + .navigationBarsPadding(), + bottomSheetState = bottomSheetDialogState, + closeSheet = { + coroutineScope.launch { + bottomSheetDialogState.hide() + } + }, + onReport = vm::report, + onCopyLink = vm::copyExamDynamicLink, + types = persistentListOf( + DuckieSelectableType.CopyLink, + DuckieSelectableType.Report, + ), ) { - Column( - modifier = Modifier.background(QuackColor.White.composeColor), + Box( + modifier = Modifier + .fillMaxSize() + .background(QuackColor.White.value) + .addFocusCleaner(), + contentAlignment = Alignment.Center, ) { - SearchTextFieldTopBar( - searchKeyword = state.searchKeyword, - onSearchKeywordChanged = { keyword -> - vm.updateSearchKeyword(keyword = keyword) - }, - onPrevious = { - finishWithAnimation() - }, - clearSearchKeyword = { - vm.clearSearchKeyword() - }, - ) - QuackAnimatedContent( - targetState = state.searchStep, - ) { step -> - when (step) { - SearchStep.Search -> SearchScreen(vm = vm) - SearchStep.SearchResult -> { - if (state.isSearchProblemError && - state.tagSelectedTab == SearchResultStep.DuckExam - ) { - ErrorScreen( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding(), - false, - onRetryClick = { - vm.fetchSearchExams(state.searchKeyword) - }, - ) - } else if (state.isSearchUserError && - state.tagSelectedTab == SearchResultStep.User - ) { - ErrorScreen( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding(), - false, - onRetryClick = { - vm.fetchSearchUsers(state.searchKeyword) - }, - ) - } else { - SearchResultScreen( - navigateDetail = { examId -> - vm.navigateToDetail(examId = examId) - }, - ) + Column { + systemBarPaddings + SearchTextFieldTopBar( + searchKeyword = searchText, + onSearchKeywordChanged = { keyword -> + vm.updateSearchKeyword(keyword = keyword) + }, + onPrevious = { + finishWithAnimation() + }, + clearSearchKeyword = { + vm.clearSearchKeyword() + }, + onAction = { + focusManager.clearFocus() + }, + focusRequester = focusRequester, + ) + AnimatedContent( + targetState = state.searchStep, + label = "AnimatedContent", + ) { step -> + when (step) { + SearchStep.Search -> SearchScreen( + vm = vm, + onSearchComplete = { + focusManager.clearFocus() + }, + ) + + SearchStep.SearchResult -> { + if (state.isSearchProblemError && + state.tagSelectedTab == SearchResultStep.DuckExam + ) { + ErrorScreen( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + false, + onRetryClick = { + vm.fetchSearchExams(searchText) + }, + ) + } else if (state.isSearchUserError && + state.tagSelectedTab == SearchResultStep.User + ) { + ErrorScreen( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + false, + onRetryClick = { + vm.fetchSearchUsers(searchText) + }, + ) + } else { + SearchResultScreen( + navigateDetail = { examId -> + vm.navigateToDetail(examId = examId) + }, + openBottomSheet = { examId -> + vm.setTargetExamId(examId = examId) + coroutineScope.launch { + keyboardController?.hide() + bottomSheetDialogState.show() + } + }, + ) + } } } } } - } - if (state.isSearchLoading) { - DuckieCircularProgressIndicator() + if (state.isSearchLoading) { + DuckieCircularProgressIndicator() + } } } } } } - private fun handleSideEffect(sideEffect: SearchSideEffect) { + private fun handleSideEffect( + sideEffect: SearchSideEffect, + examPagingItems: LazyPagingItems, + ) { when (sideEffect) { is SearchSideEffect.ReportError -> { Firebase.crashlytics.recordException(sideEffect.exception) @@ -188,6 +284,18 @@ class SearchActivity : BaseActivity() { }, ) } + + is SearchSideEffect.SendToast -> { + Toast.makeText(this, sideEffect.message, Toast.LENGTH_SHORT).show() + } + + is SearchSideEffect.CopyDynamicLink -> { + DynamicLinkHelper.createAndShareLink(this, sideEffect.examId) + } + + SearchSideEffect.ExamRefresh -> { + examPagingItems.refresh() + } } } } @@ -199,6 +307,8 @@ private fun SearchTextFieldTopBar( onSearchKeywordChanged: (String) -> Unit, clearSearchKeyword: () -> Unit, onPrevious: () -> Unit, + onAction: () -> Unit, + focusRequester: FocusRequester, ) { Row( modifier = modifier @@ -210,21 +320,23 @@ private fun SearchTextFieldTopBar( ), verticalAlignment = Alignment.CenterVertically, ) { - QuackImage( - src = QuackIcon.ArrowBack, - size = DpSize(all = 24.dp), - onClick = onPrevious, + QuackIcon( + modifier = Modifier.quackClickable(onClick = onPrevious), + icon = QuackIcon.Outlined.ArrowBack, ) Spacer(space = 8.dp) QuackNoUnderlineTextField( + modifier = Modifier.focusRequester(focusRequester), text = searchKeyword, - onTextChanged = { keyword -> - onSearchKeywordChanged(keyword) - }, + onTextChanged = onSearchKeywordChanged, placeholderText = stringResource(id = R.string.try_search), - trailingIcon = SharedIcon.ic_textfield_delete_16, + trailingIcon = if (searchKeyword.isNotEmpty()) SharedIcon.ic_textfield_delete_16 else null, trailingIconOnClick = clearSearchKeyword, - trailingEndPadding = 12.dp, + keyboardActions = KeyboardActions( + onDone = { + onAction() + }, + ), ) } } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchResultScreen.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchResultScreen.kt index 4a3b2c274..b62ad035e 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchResultScreen.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchResultScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -32,49 +33,54 @@ import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.ui.DuckExamSmallCover import team.duckie.app.android.common.compose.ui.DuckTestCoverItem import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.compose.ui.UserFollowingLayout +import team.duckie.app.android.common.compose.ui.content.UserFollowingLayout +import team.duckie.app.android.common.kotlin.fastForEach import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.feature.search.R import team.duckie.app.android.feature.search.constants.SearchResultStep import team.duckie.app.android.feature.search.viewmodel.SearchViewModel import team.duckie.app.android.feature.search.viewmodel.state.SearchState -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody1 -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackMainTab +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackTab +import team.duckie.quackquack.ui.QuackText @Composable internal fun SearchResultScreen( modifier: Modifier = Modifier, vm: SearchViewModel = activityViewModel(), navigateDetail: (Int) -> Unit, + openBottomSheet: (Int) -> Unit, ) { val state = vm.collectAsState().value val searchUsers = vm.searchUsers.collectAsLazyPagingItems() val searchExams = vm.searchExams.collectAsLazyPagingItems() - val tabTitles = SearchResultStep.values().map { - it.title - }.toPersistentList() + val tabTitles = remember { + SearchResultStep.values().map { step -> + step.title + }.toPersistentList() + } Column( modifier = modifier .fillMaxSize() .nestedScroll(rememberNestedScrollInteropConnection()), ) { - QuackMainTab( - titles = tabTitles, - selectedTabIndex = state.tagSelectedTab.index, - onTabSelected = { index -> - vm.updateSearchResultTab(SearchResultStep.toStep(index)) - }, - ) + QuackTab(index = state.tagSelectedTab.index) { + tabTitles.fastForEach { label -> + tab(label) { index -> + vm.updateSearchResultTab(SearchResultStep.toStep(index)) + } + } + } when (state.tagSelectedTab) { SearchResultStep.DuckExam -> { SearchResultForExam( searchExams = searchExams, navigateDetail = navigateDetail, + onMoreClick = openBottomSheet, ) } @@ -108,14 +114,18 @@ private fun SearchResultForUser( .padding(top = 60.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - QuackHeadLine1( + QuackText( text = stringResource(id = R.string.no_search_user), - color = QuackColor.Gray1, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.Gray1, + ), ) Spacer(space = 12.dp) - QuackBody1( + QuackText( text = stringResource(id = R.string.search_another_keyword), - color = QuackColor.Gray1, + typography = QuackTypography.Body1.change( + color = QuackColor.Gray1, + ), ) } } else { @@ -128,10 +138,10 @@ private fun SearchResultForUser( favoriteTag = item?.favoriteTag ?: "", tier = item?.tier ?: "", isFollowing = item?.isFollowing ?: false, - onClickFollow = { follow -> + onClickTrailingButton = { follow -> onClickFollow(item?.userId ?: 0, follow) }, - isMine = myUserId == item?.userId, + visibleTrailingButton = myUserId != item?.userId, onClickUserProfile = onClickUserProfile, ) } @@ -143,6 +153,7 @@ private fun SearchResultForUser( private fun SearchResultForExam( searchExams: LazyPagingItems, navigateDetail: (Int) -> Unit, + onMoreClick: (Int) -> Unit, ) { if (searchExams.itemCount == 0) { Column( @@ -151,18 +162,23 @@ private fun SearchResultForExam( .padding(top = 60.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - QuackHeadLine1( + QuackText( text = stringResource(id = R.string.no_search_exam), - color = QuackColor.Gray1, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.Gray1, + ), ) Spacer(space = 12.dp) - QuackBody1( + QuackText( text = stringResource(id = R.string.search_another_keyword), - color = QuackColor.Gray1, + typography = QuackTypography.Body1.change( + color = QuackColor.Gray1, + ), ) } } else { LazyVerticalGrid( + modifier = Modifier.padding(top = 20.dp), columns = GridCells.Fixed(2), state = rememberLazyGridState(), verticalArrangement = Arrangement.spacedBy(48.dp), @@ -183,7 +199,9 @@ private fun SearchResultForExam( onItemClick = { navigateDetail(exam?.id ?: 0) }, - onMoreClick = {}, + onMoreClick = { + onMoreClick(exam?.id ?: 0) + }, ) } } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchScreen.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchScreen.kt index 1949f6376..1d8d3e6d1 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchScreen.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchScreen.kt @@ -36,19 +36,22 @@ import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.domain.tag.model.Tag import team.duckie.app.android.feature.search.R import team.duckie.app.android.feature.search.viewmodel.SearchViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody1 -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.icon.quackicon.outlined.Search +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackBody1 +import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun SearchScreen( vm: SearchViewModel, + onSearchComplete: () -> Unit, ) { LaunchedEffect(Unit) { vm.getRecentSearch() @@ -61,7 +64,7 @@ internal fun SearchScreen( .fillMaxSize() .imePadding(), ) { - Spacer(modifier = Modifier.height(22.dp)) + Spacer(modifier = Modifier.height(16.dp)) if (state.recentSearch.isEmpty()) { RecentSearchNotFoundScreen() } else { @@ -75,6 +78,7 @@ internal fun SearchScreen( vm.clearRecentSearch(keyword = keyword) }, navigateToResult = { keyword -> + onSearchComplete() vm.updateSearchKeyword( keyword = keyword, debounce = false, @@ -98,14 +102,18 @@ private fun RecentSearchNotFoundScreen() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - QuackHeadLine1( + QuackText( text = stringResource(id = R.string.no_recent_search), - color = QuackColor.Gray1, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.Gray1, + ), ) Spacer(space = 12.dp) - QuackBody1( + QuackText( text = stringResource(id = R.string.search_favorite_exam), - color = QuackColor.Gray1, + typography = QuackTypography.Body1.change( + color = QuackColor.Gray1, + ), ) } } @@ -125,9 +133,9 @@ private fun LazyListScope.recommendKeywordSection( modifier = Modifier .fillMaxWidth() .height(44.dp) - .quackClickable { - onClickedSearch() - }, + .quackClickable( + onClick = onClickedSearch, + ), contentAlignment = Alignment.CenterStart, ) { QuackTitle2(text = tag?.name ?: "") // TODO(limsaehyun): QuackAnnotationTitle2 교체 필요 @@ -154,12 +162,14 @@ private fun LazyListScope.recentKeywordSection( ) { QuackTitle2(text = stringResource(id = R.string.recent_search)) Spacer(modifier = Modifier.weight(1f)) - QuackBody2( + QuackText( + modifier = Modifier.quackClickable( + onClick = onClickedClearAll, + ), text = stringResource(id = R.string.clear_all), - color = QuackColor.Gray1, - onClick = { - onClickedClearAll() - }, + typography = QuackTypography.Body2.change( + color = QuackColor.Gray1, + ), ) } } @@ -189,19 +199,23 @@ private fun RecentSearchLayout( .padding(SearchHorizontalPadding) .padding(vertical = 12.dp), ) { - QuackImage( - src = QuackIcon.Search, - size = DpSize(16.dp), + QuackIcon( + icon = QuackIcon.Outlined.Search, + size = 16.dp, tint = QuackColor.Gray2, ) Spacer(modifier = Modifier.width(8.dp)) QuackBody1(text = keyword) Spacer(modifier = Modifier.weight(1f)) - QuackImage( - src = QuackIcon.Close, - size = DpSize(16.dp), + QuackIcon( + modifier = Modifier.quackClickable( + onClick = { + onCloseClick(keyword) + }, + ), + icon = QuackIcon.Outlined.Close, + size = 16.dp, tint = QuackColor.Gray2, - onClick = { onCloseClick(keyword) }, ) } } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/SearchViewModel.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/SearchViewModel.kt index c9fd10083..16b21e11b 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/SearchViewModel.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/SearchViewModel.kt @@ -7,6 +7,7 @@ package team.duckie.app.android.feature.search.viewmodel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.LoadState @@ -21,6 +22,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map @@ -31,9 +33,13 @@ import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container import team.duckie.app.android.common.android.ui.const.Debounce +import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.common.compose.ui.dialog.ReportAlreadyExists +import team.duckie.app.android.common.kotlin.exception.isReportAlreadyExists import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.domain.follow.model.FollowBody import team.duckie.app.android.domain.follow.usecase.FollowUseCase +import team.duckie.app.android.domain.report.usecase.ReportUseCase import team.duckie.app.android.domain.search.usecase.ClearAllRecentSearchUseCase import team.duckie.app.android.domain.search.usecase.ClearRecentSearchUseCase import team.duckie.app.android.domain.search.usecase.GetRecentSearchUseCase @@ -59,6 +65,8 @@ internal class SearchViewModel @Inject constructor( private val clearRecentSearchUseCase: ClearRecentSearchUseCase, private val followUseCase: FollowUseCase, private val getMeUseCase: GetMeUseCase, + private val reportUseCase: ReportUseCase, + private val savedStateHandle: SavedStateHandle, ) : ContainerHost, ViewModel() { override val container = container(SearchState()) @@ -70,8 +78,18 @@ internal class SearchViewModel @Inject constructor( MutableStateFlow>(PagingData.empty()) val searchUsers: Flow> = _searchUsers + private val _searchText = MutableStateFlow("") + val searchText: StateFlow = _searchText + init { initState() + getAutoFocusing() + } + + private fun getAutoFocusing() = intent { + val autoFocusing = savedStateHandle.getStateFlow(Extras.AutoFocusing, true).value + + reduce { state.copy(searchAutoFocusing = autoFocusing) } } /** [SearchViewModel]의 초기 상태를 설정한다. */ @@ -94,7 +112,7 @@ internal class SearchViewModel @Inject constructor( ).apply { intent { this@apply.debounce(Debounce.SearchSecond).collectLatest { query -> - refreshSearchStep(keyword = state.searchKeyword) + refreshSearchStep(keyword = _searchText.value) // TODO(limsaehyun): 추후 추천 검색어 비즈니스 로직을 이곳에서 작업해야 함 } } @@ -149,6 +167,12 @@ internal class SearchViewModel @Inject constructor( } } + fun updateReportDialogVisible(visible: Boolean) = intent { + reduce { + state.copy(reportDialogVisible = visible) + } + } + /** [keyword]에 따른 덕질고사 검색 결과를 가져온다. */ internal fun fetchSearchExams(keyword: String) { intent { reduce { state.copy(isSearchProblemError = false) } } @@ -178,6 +202,35 @@ internal class SearchViewModel @Inject constructor( } } + fun setTargetExamId(examId: Int) = intent { + reduce { + state.copy(targetExamId = examId) + } + } + + fun report() = intent { + reportUseCase(state.targetExamId) + .onSuccess { + updateReportDialogVisible(true) + postSideEffect(SearchSideEffect.ExamRefresh) + } + .onFailure { exception -> + when { + exception.isReportAlreadyExists -> postSideEffect( + SearchSideEffect.SendToast(ReportAlreadyExists), + ) + + else -> postSideEffect(SearchSideEffect.ReportError(exception)) + } + } + } + + fun copyExamDynamicLink() = intent { + val examId = state.targetExamId + + postSideEffect(SearchSideEffect.CopyDynamicLink(examId)) + } + /** 검색 화면에서 [query] 값에 맞는 검색 결과를 가져온다. */ private suspend fun recommendKeywords(query: String) { _getRecommendKeywords.emit(query) @@ -187,10 +240,9 @@ internal class SearchViewModel @Inject constructor( fun updateSearchKeyword( keyword: String, debounce: Boolean = true, - ) = intent { - reduce { - state.copy(searchKeyword = keyword) - }.run { + ) { + viewModelScope.launch { + _searchText.value = keyword recommendKeywords(query = keyword) if (!debounce) refreshSearchStep(keyword = keyword) } @@ -206,12 +258,12 @@ internal class SearchViewModel @Inject constructor( if (keyword.isEmpty()) { navigateSearchStep( step = SearchStep.Search, - keyword = state.searchKeyword, + keyword = _searchText.value, ) } else { navigateSearchStep( step = SearchStep.SearchResult, - keyword = state.searchKeyword, + keyword = _searchText.value, ) } } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/sideeffect/SearchSideEffect.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/sideeffect/SearchSideEffect.kt index 1e579a95f..f7c4a00eb 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/sideeffect/SearchSideEffect.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/sideeffect/SearchSideEffect.kt @@ -19,4 +19,10 @@ internal sealed class SearchSideEffect { class NavigateToDetail(val examId: Int) : SearchSideEffect() class NavigateToUserProfile(val userId: Int) : SearchSideEffect() + + class SendToast(val message: String) : SearchSideEffect() + + object ExamRefresh : SearchSideEffect() + + class CopyDynamicLink(val examId: Int) : SearchSideEffect() } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/state/SearchState.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/state/SearchState.kt index b1ea923d6..fce65e535 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/state/SearchState.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/state/SearchState.kt @@ -7,6 +7,7 @@ package team.duckie.app.android.feature.search.viewmodel.state +import androidx.compose.runtime.Immutable import androidx.paging.PagingData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -27,6 +28,7 @@ import team.duckie.app.android.feature.search.constants.SearchStep * [searchKeyword] 검색어 * [tagSelectedTab] 검색 결과에서 선택된 탭 */ +@Immutable internal data class SearchState( val me: User? = null, val isSearchLoading: Boolean = false, @@ -35,8 +37,11 @@ internal data class SearchState( val searchStep: SearchStep = SearchStep.Search, val recentSearch: ImmutableList = persistentListOf(), val recommendSearchs: Flow> = flow { PagingData.empty() }, - val searchKeyword: String = "", val tagSelectedTab: SearchResultStep = SearchResultStep.DuckExam, + val targetExamId: Int = 0, + val bottomSheetVisible: Boolean = false, + val reportDialogVisible: Boolean = false, + val searchAutoFocusing: Boolean = false, ) { data class SearchUser( val userId: Int, diff --git a/feature/setting/build.gradle.kts b/feature/setting/build.gradle.kts index 4fa518d00..c754a1f31 100644 --- a/feature/setting/build.gradle.kts +++ b/feature/setting/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { projects.feature.devMode, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, + libs.kotlin.collections.immutable, libs.quack.v2.ui, libs.compose.lifecycle.runtime, libs.compose.ui.accompanist.webview, diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/component/SettingContentLayout.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/component/SettingContentLayout.kt index a8e8f77c8..fb6333187 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/component/SettingContentLayout.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/component/SettingContentLayout.kt @@ -10,50 +10,52 @@ package team.duckie.app.android.feature.setting.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.util.fillMaxScreenWidth import team.duckie.app.android.feature.setting.constans.SettingDesignToken +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.sugar.QuackBody1 -import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun SettingContentLayout( + modifier: Modifier = Modifier, title: String, content: String? = null, trailingText: String? = null, onTrailingTextClick: (() -> Unit)? = null, - isBold: Boolean, + typography: QuackTypography = QuackTypography.Title2, + horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween, onClick: (() -> Unit)? = null, ) = with(SettingDesignToken) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .height(44.dp) + .fillMaxScreenWidth() .quackClickable( - rippleEnabled = false, - ) { - if (onClick != null) { - onClick() - } - }, + rippleEnabled = true, + onClick = { + if (onClick != null) { + onClick() + } + }, + ) + .padding( + horizontal = 16.dp, + vertical = 12.dp, + ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = horizontalArrangement, ) { - if (isBold) { - QuackTitle2( - text = title, - ) - } else { - QuackBody1( - text = title, - ) - } + QuackText( + text = title, + typography = typography, + ) if (content != null) { QuackText( modifier = Modifier.padding(start = 12.dp), @@ -61,6 +63,7 @@ internal fun SettingContentLayout( typography = SettingHorizontalResultTypography, ) } + Spacer(weight = 1f) if (trailingText != null) { QuackText( modifier = Modifier diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/SettingType.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/SettingType.kt index 1638d8d61..33cdd071e 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/SettingType.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/SettingType.kt @@ -12,9 +12,12 @@ import kotlinx.collections.immutable.persistentListOf import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.constans.SettingType.AccountInfo import team.duckie.app.android.feature.setting.constans.SettingType.Inquiry +import team.duckie.app.android.feature.setting.constans.SettingType.ListOfIgnoreExam +import team.duckie.app.android.feature.setting.constans.SettingType.ListOfIgnoreUser import team.duckie.app.android.feature.setting.constans.SettingType.Main import team.duckie.app.android.feature.setting.constans.SettingType.MainPolicy import team.duckie.app.android.feature.setting.constans.SettingType.Notification +import team.duckie.app.android.feature.setting.constans.SettingType.PrivacyPolicy import team.duckie.app.android.feature.setting.constans.SettingType.Version /** @@ -31,6 +34,12 @@ enum class SettingType( @StringRes val titleRes: Int, ) { + ListOfIgnoreUser( + titleRes = R.string.list_of_ignore_user, + ), + ListOfIgnoreExam( + titleRes = R.string.list_of_ignore_exam, + ), Main( titleRes = R.string.app_setting, ), @@ -67,16 +76,16 @@ enum class SettingType( PrivacyPolicy, ) - /** [AccountInfo] 안에 위치한 설정 */ - private val accountInfoPages = persistentListOf( - WithDraw, + val userSettings = persistentListOf( + AccountInfo, + ListOfIgnoreUser, + ListOfIgnoreExam, ) - /** 메인 설정 페이지에 표시될 설정 */ - val settingPages = SettingType - .values() - .filter { it !in listOf(Main) } - .filter { it !in policyPages } - .filter { it !in accountInfoPages } + val otherSettings = persistentListOf( + Inquiry, + MainPolicy, + Version, + ) } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/Withdraweason.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/Withdraweason.kt index a0c2e720e..6e1543164 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/Withdraweason.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/Withdraweason.kt @@ -40,7 +40,7 @@ internal enum class Withdraweason( /** 기타 */ OTHERS( - description = R.string.withdraw_others, + description = R.string.others, ), ; diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingAccountInfoScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingAccountInfoScreen.kt index b1886f905..00bbcfa8f 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingAccountInfoScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingAccountInfoScreen.kt @@ -7,40 +7,29 @@ package team.duckie.app.android.feature.setting.screen -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider -import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.component.SettingContentLayout import team.duckie.app.android.feature.setting.constans.SettingDesignToken -import team.duckie.quackquack.ui.QuackImage -import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.sugar.QuackBody1 +import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.ui.sugar.QuackSubtitle2 -private val KakaoColor: Color = Color(0xFFFEE500) +// private val KakaoColor: Color = Color(0xFFFEE500) +@Suppress("UnusedPrivateMember") // TODO(limsaehyun) 추후 이메일 작업 필요 @Composable fun SettingAccountInfoScreen( email: String, @@ -61,36 +50,37 @@ fun SettingAccountInfoScreen( modifier = Modifier.padding(vertical = 12.dp), text = stringResource(id = R.string.sign_in_account), ) - Row( - modifier = Modifier - .fillMaxWidth() - .height(44.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - QuackBody1( - text = stringResource(id = R.string.email), - ) - Spacer(space = 12.dp) - Box( - modifier = Modifier - .size(18.dp) - .background( - color = KakaoColor, - shape = RoundedCornerShape(2.dp), - ), - contentAlignment = Alignment.Center, - ) { - QuackImage( - modifier = Modifier.size(12.dp, 10.dp), - src = R.drawable.ic_setting_kakao, - ) - } - Spacer(space = 4.dp) - QuackText( - text = email, - typography = SettingHorizontalResultTypography, - ) - } +// 이메일 로직: 필요시 주석 해제 후 사용 +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .height(44.dp), +// verticalAlignment = Alignment.CenterVertically, +// ) { +// QuackBody1( +// text = stringResource(id = R.string.email), +// ) +// Spacer(space = 12.dp) +// Box( +// modifier = Modifier +// .size(18.dp) +// .background( +// color = KakaoColor, +// shape = RoundedCornerShape(2.dp), +// ), +// contentAlignment = Alignment.Center, +// ) { +// QuackImage( +// modifier = Modifier.size(12.dp, 10.dp), +// src = R.drawable.ic_setting_kakao, +// ) +// } +// Spacer(space = 4.dp) +// QuackText( +// text = email, +// typography = SettingHorizontalResultTypography, +// ) +// } QuackMaxWidthDivider(modifier = Modifier.padding(vertical = 16.dp)) LazyColumn( verticalArrangement = Arrangement.spacedBy(4.dp), @@ -98,8 +88,8 @@ fun SettingAccountInfoScreen( items(rememberAccountInfoItems) { index -> SettingContentLayout( title = stringResource(id = index.first), - isBold = false, onClick = index.second, + typography = QuackTypography.Body1, ) } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingActivity.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingActivity.kt index 4d196cd26..3161bd410 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingActivity.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingActivity.kt @@ -34,6 +34,7 @@ import team.duckie.app.android.common.android.ui.finishWithAnimation import team.duckie.app.android.common.android.ui.startActivityWithAnimation import team.duckie.app.android.common.compose.ToastWrapper import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.BackPressedHeadLine2TopAppBar import team.duckie.app.android.common.compose.ui.DuckieTodoScreen import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade @@ -47,8 +48,6 @@ import team.duckie.app.android.feature.setting.viewmodel.state.SettingState import team.duckie.app.android.navigator.feature.intro.IntroNavigator import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.theme.QuackTheme -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon import javax.inject.Inject @AndroidEntryPoint @@ -84,13 +83,9 @@ class SettingActivity : BaseActivity() { .background(QuackColor.White.value) .padding(systemBarPaddings), ) { - QuackTopAppBar( - leadingIcon = QuackIcon.ArrowBack, - leadingText = stringResource(id = state.settingType.titleRes), - onLeadingIconClick = { - vm.navigateBack() - }, - ) + BackPressedHeadLine2TopAppBar(title = stringResource(id = state.settingType.titleRes)) { + vm.navigateBack() + } Column( modifier = Modifier .padding( @@ -138,6 +133,16 @@ class SettingActivity : BaseActivity() { state = state, ) + SettingType.ListOfIgnoreUser -> SettingIgnoreUserScreen( + state = state, + vm = vm, + ) + + SettingType.ListOfIgnoreExam -> SettingIgnoreExamScreen( + vm = vm, + state = state, + ) + else -> Unit } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreExamScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreExamScreen.kt new file mode 100644 index 000000000..aafced5f8 --- /dev/null +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreExamScreen.kt @@ -0,0 +1,63 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.feature.setting.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import team.duckie.app.android.common.compose.ui.content.ExamIgnoreLayout +import team.duckie.app.android.feature.setting.R +import team.duckie.app.android.feature.setting.viewmodel.SettingViewModel +import team.duckie.app.android.feature.setting.viewmodel.state.SettingState +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText + +@Composable +internal fun SettingIgnoreExamScreen( + vm: SettingViewModel, + state: SettingState, +) { + LaunchedEffect(key1 = Unit) { + vm.getIgnoreExams() + } + + if (state.ignoreExams.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + QuackText( + text = stringResource(id = R.string.setting_ignore_exam_not_found), + typography = QuackTypography.HeadLine1.change( + color = QuackColor.DuckieOrange, + ), + ) + } + } else { + LazyColumn { + items(state.ignoreExams) { item -> + ExamIgnoreLayout( + examId = item.id, + examThumbnailUrl = item.thumbnailUrl, + name = item.title, + onClickTrailingButton = { examId -> + vm.cancelIgnoreExam(examId = examId) + }, + rippleEnabled = false, + ) + } + } + } +} diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreUserScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreUserScreen.kt new file mode 100644 index 000000000..4abf51365 --- /dev/null +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreUserScreen.kt @@ -0,0 +1,65 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.feature.setting.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import team.duckie.app.android.common.compose.ui.content.UserIgnoreLayout +import team.duckie.app.android.feature.setting.R +import team.duckie.app.android.feature.setting.viewmodel.SettingViewModel +import team.duckie.app.android.feature.setting.viewmodel.state.SettingState +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText + +@Composable +internal fun SettingIgnoreUserScreen( + vm: SettingViewModel, + state: SettingState, +) { + LaunchedEffect(key1 = Unit) { + vm.getIgnoreUsers() + } + + if (state.ignoreUsers.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + QuackText( + text = stringResource(id = R.string.setting_ignore_user_not_found), + typography = QuackTypography.HeadLine1.change( + color = QuackColor.DuckieOrange, + ), + ) + } + } else { + LazyColumn { + items(state.ignoreUsers) { item -> + UserIgnoreLayout( + userId = item.id, + profileImageIrl = item.profileImageUrl, + nickname = item.nickName, + favoriteTag = item.duckPower?.tag?.name ?: "", + tier = item.duckPower?.tier ?: "", + onClickTrailingButton = { userId -> + vm.cancelIgnoreUser(userId) + }, + rippleEnabled = false, + ) + } + } + } +} diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingInquiryScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingInquiryScreen.kt index d1a5e08bd..229f38ab9 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingInquiryScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingInquiryScreen.kt @@ -22,6 +22,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.component.SettingContentLayout +import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.ui.sugar.QuackSubtitle2 /** @@ -57,7 +58,8 @@ fun SettingInquiryScreen() { SettingContentLayout( title = stringResource(id = item.first), content = item.second, - isBold = false, + typography = QuackTypography.Body1, + horizontalArrangement = Arrangement.Start, ) } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainPolicyScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainPolicyScreen.kt index 73c8cd4f5..6308912c9 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainPolicyScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainPolicyScreen.kt @@ -20,6 +20,7 @@ import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.component.SettingContentLayout import team.duckie.app.android.feature.setting.constans.SettingType +import team.duckie.quackquack.material.QuackTypography @Composable fun SettingMainPolicyScreen( @@ -36,7 +37,7 @@ fun SettingMainPolicyScreen( items(SettingType.policyPages) { page -> SettingContentLayout( title = stringResource(id = page.titleRes), - isBold = false, + typography = QuackTypography.Body1, ) { navigatePage(page) } @@ -46,7 +47,7 @@ fun SettingMainPolicyScreen( title = stringResource( id = R.string.open_source_license, ), - isBold = false, + typography = QuackTypography.Body1, ) { navigateOssLicense() } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainScreen.kt index cff063028..4006ce241 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainScreen.kt @@ -7,32 +7,68 @@ package team.duckie.app.android.feature.setting.screen +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.rememberToast +import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider +import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.component.SettingContentLayout import team.duckie.app.android.feature.setting.constans.SettingType import team.duckie.app.android.feature.setting.viewmodel.SettingViewModel +import team.duckie.quackquack.material.QuackTypography @Composable internal fun SettingMainScreen( vm: SettingViewModel, version: String, ) { - val settingItems = SettingType.settingPages + val userSettings = SettingType.userSettings + val otherSettings = SettingType.otherSettings + val toast = rememberToast() LazyColumn { - items(settingItems) { item -> + titleSection(title = R.string.user_settings) + items(userSettings) { item -> SettingContentLayout( title = stringResource(id = item.titleRes), + onClick = { vm.navigateStep(item) }, + typography = QuackTypography.Body1, + ) + } + item { + Spacer(modifier = Modifier.height(12.dp)) + QuackMaxWidthDivider() + SettingContentLayout( + modifier = Modifier + .padding(vertical = 12.dp), + title = stringResource(id = R.string.notification), + typography = QuackTypography.Body1, + onClick = { + toast.invoke("개발 중인 기능입니다!") // TODO(limsaehyun) 알림 develop 필요 + }, + ) + QuackMaxWidthDivider() + } + titleSection(title = R.string.others) + items(otherSettings) { item -> + SettingContentLayout( + title = stringResource(id = item.titleRes), + typography = QuackTypography.Body1, trailingText = if (item == SettingType.Version) version else null, - onTrailingTextClick = if (item == SettingType.Version) { - { + onTrailingTextClick = { + if (item == SettingType.Version) { vm.changeDevModeDialogVisible(true) } - } else { - null }, onClick = { when (item) { @@ -45,8 +81,18 @@ internal fun SettingMainScreen( } } }, - isBold = true, ) } } } + +internal fun LazyListScope.titleSection( + @StringRes title: Int, +) { + item { + SettingContentLayout( + title = LocalContext.current.getString(title), + typography = QuackTypography.Title2, + ) + } +} diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingNotificationScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingNotificationScreen.kt index 03f5aa9dc..6dfbf3f00 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingNotificationScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingNotificationScreen.kt @@ -4,6 +4,7 @@ * Licensed under the MIT. * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:Suppress("UnusedPrivateMember") package team.duckie.app.android.feature.setting.screen @@ -21,10 +22,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import team.duckie.app.android.feature.setting.constans.SettingNotificationType import team.duckie.app.android.feature.setting.constans.SettingDesignToken +import team.duckie.app.android.feature.setting.constans.SettingNotificationType import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.component.QuackSwitch import team.duckie.quackquack.ui.sugar.QuackBody1 /** @@ -51,6 +51,7 @@ fun SettingNotificationScreen() { } } +@Suppress("unused") @Composable private fun SettingNotificationLayout( title: String, @@ -75,9 +76,10 @@ private fun SettingNotificationLayout( ) } Spacer(modifier = Modifier.weight(1f)) - QuackSwitch( - checked = checked, - onCheckedChange = onCheckedChange, - ) +// QuackSwitch( +// checked = checked, +// onCheckedChange = onCheckedChange, +// ) +// TODO(limsaehyun) [QuackQuack] Switch 작업 필요! } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingWithdrawScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingWithdrawScreen.kt index 0ced38060..1dbe58b55 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingWithdrawScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingWithdrawScreen.kt @@ -5,11 +5,15 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.setting.screen import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -30,20 +34,23 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.quack.todo.QuackReactionTextArea +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackRoundCheckBox +import team.duckie.app.android.common.kotlin.runIf import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.constans.Withdraweason import team.duckie.app.android.feature.setting.viewmodel.SettingViewModel import team.duckie.app.android.feature.setting.viewmodel.state.SettingState -import team.duckie.quackquack.animation.QuackAnimatedVisibility +import team.duckie.quackquack.animation.animateQuackColorAsState import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackImage -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackReviewTextArea -import team.duckie.quackquack.ui.component.QuackRoundCheckBox -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackBody1 import team.duckie.quackquack.ui.sugar.QuackHeadLine2 +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun SettingWithdrawScreen( @@ -92,8 +99,8 @@ internal fun SettingWithdrawScreen( ) } item { - QuackAnimatedVisibility(visible = state.withdrawReasonSelected == Withdraweason.OTHERS) { - QuackReviewTextArea( + AnimatedVisibility(visible = state.withdrawReasonSelected == Withdraweason.OTHERS) { + QuackReactionTextArea( modifier = Modifier .padding(top = 4.dp) .height(140.dp) @@ -101,15 +108,14 @@ internal fun SettingWithdrawScreen( .onFocusChanged { state -> vm.updateWithDrawFocus(state.isFocused) }, - text = state.withdrawUserInputReason, - onTextChanged = { text -> + reaction = state.withdrawUserInputReason, + onReactionChanged = { text -> vm.updateWithdrawUserInputReason(text) }, - focused = state.withdrawIsFocused, - placeholderText = stringResource( - // TODO(limsaehyun): placeholder의 maxline을 설정할 수 있어야 함 + placeHolderText = stringResource( id = R.string.withdraw_others_text_field_hint, ), + visibleCurrentLength = false, ) } } @@ -124,20 +130,49 @@ internal fun SettingWithdrawScreen( .weight(1f) .height(44.dp) - QuackLargeButton( - modifier = buttonModifier, - type = QuackLargeButtonType.Border, - text = stringResource(id = R.string.withdraw_cancel_msg), - onClick = vm::navigateBack, - ) + val buttonEnabled = state.withdrawReasonSelected != Withdraweason.INITIAL + + val primaryButtonColor = + animateQuackColorAsState(targetValue = if (buttonEnabled) QuackColor.DuckieOrange else QuackColor.Gray2) + + Box( + modifier = buttonModifier + .background( + color = QuackColor.White.value, + shape = RoundedCornerShape(8.dp), + ) + .border( + width = 1.dp, + shape = RoundedCornerShape(8.dp), + color = QuackColor.Gray3.value, + ) + .quackClickable( + onClick = vm::navigateBack, + ), + contentAlignment = Alignment.Center, + ) { + QuackSubtitle(text = stringResource(id = R.string.withdraw_cancel_msg)) + } Spacer(space = 8.dp) - QuackLargeButton( - modifier = buttonModifier, - type = QuackLargeButtonType.Fill, - text = stringResource(id = R.string.withdraw), - enabled = state.withdrawReasonSelected != Withdraweason.INITIAL, + Box( + modifier = buttonModifier + .background( + color = primaryButtonColor.value.value, + shape = RoundedCornerShape(8.dp), + ) + .runIf(buttonEnabled) { + quackClickable( + onClick = { vm.changeWithdrawDialogVisible(true) }, + ) + }, + contentAlignment = Alignment.Center, ) { - vm.changeWithdrawDialogVisible(true) + QuackText( + text = stringResource(id = R.string.withdraw), + typography = QuackTypography.Subtitle.change( + color = QuackColor.White, + ), + ) } } } @@ -157,9 +192,11 @@ internal fun SettingCheckBox( Row( modifier = modifier .fillMaxWidth() - .quackClickable { - onClick(reason) - } + .quackClickable( + onClick = { + onClick(reason) + }, + ) .clip(RoundedCornerShape(8.dp)) .background( color = QuackColor.Gray4.value, diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/SettingViewModel.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/SettingViewModel.kt index 776d76ccd..2b24df1c6 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/SettingViewModel.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/SettingViewModel.kt @@ -9,6 +9,7 @@ package team.duckie.app.android.feature.setting.viewmodel import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect @@ -16,7 +17,11 @@ import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container import team.duckie.app.android.common.kotlin.seconds import team.duckie.app.android.domain.auth.usecase.ClearTokenUseCase +import team.duckie.app.android.domain.exam.usecase.CancelExamIgnoreUseCase +import team.duckie.app.android.domain.exam.usecase.GetExamIgnoresUseCase +import team.duckie.app.android.domain.ignore.usecase.CancelUserIgnoreUseCase import team.duckie.app.android.domain.me.usecase.GetIsStageUseCase +import team.duckie.app.android.domain.user.usecase.FetchIgnoreUsersUseCase import team.duckie.app.android.domain.user.usecase.GetMeUseCase import team.duckie.app.android.feature.setting.constans.SettingType import team.duckie.app.android.feature.setting.constans.SettingType.Companion.policyPages @@ -33,6 +38,10 @@ internal class SettingViewModel @Inject constructor( private val getMeUseCase: GetMeUseCase, private val getIsStageUseCase: GetIsStageUseCase, private val clearTokenUseCase: ClearTokenUseCase, + private val fetchIgnoreUsers: FetchIgnoreUsersUseCase, + private val cancelUserIgnoreUseCase: CancelUserIgnoreUseCase, + private val getIgnoreExamsUseCase: GetExamIgnoresUseCase, + private val cancelExamIgnoreUseCase: CancelExamIgnoreUseCase, ) : ContainerHost, ViewModel() { override val container = container(SettingState()) @@ -58,6 +67,48 @@ internal class SettingViewModel @Inject constructor( } } + fun getIgnoreExams() = intent { + getIgnoreExamsUseCase() + .onSuccess { exams -> + reduce { state.copy(ignoreExams = exams.toImmutableList()) } + } + .onFailure { + postSideEffect(SettingSideEffect.ReportError(it)) + } + } + + fun cancelIgnoreExam(examId: Int) = intent { + cancelExamIgnoreUseCase(examId = examId) + .onSuccess { + val ignoreExams = state.ignoreExams.filter { it.id != examId }.toImmutableList() + reduce { state.copy(ignoreExams = ignoreExams) } + } + .onFailure { + postSideEffect(SettingSideEffect.ReportError(it)) + } + } + + fun cancelIgnoreUser(userId: Int) = intent { + cancelUserIgnoreUseCase(targetId = userId) + .onSuccess { + val ignoreUsers = state.ignoreUsers.filter { it.id != userId }.toImmutableList() + reduce { state.copy(ignoreUsers = ignoreUsers) } + } + .onFailure { + postSideEffect(SettingSideEffect.ReportError(it)) + } + } + + fun getIgnoreUsers() = intent { + fetchIgnoreUsers() + .onSuccess { users -> + reduce { state.copy(ignoreUsers = users) } + } + .onFailure { + postSideEffect(SettingSideEffect.ReportError(it)) + } + } + fun updateWithdrawReason(reason: Withdraweason) = intent { reduce { state.copy(withdrawReasonSelected = reason) } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/state/SettingState.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/state/SettingState.kt index 528820cac..92fc5ecc4 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/state/SettingState.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/state/SettingState.kt @@ -7,10 +7,16 @@ package team.duckie.app.android.feature.setting.viewmodel.state +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import team.duckie.app.android.domain.exam.model.IgnoreExam +import team.duckie.app.android.domain.user.model.IgnoreUser import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.feature.setting.constans.SettingType import team.duckie.app.android.feature.setting.constans.Withdraweason +@Immutable internal data class SettingState( val me: User? = null, val isStage: Boolean = false, @@ -24,4 +30,7 @@ internal data class SettingState( val withdrawReasonSelected: Withdraweason = Withdraweason.INITIAL, val withdrawUserInputReason: String = "", val withdrawIsFocused: Boolean = false, + + val ignoreUsers: ImmutableList = persistentListOf(), + val ignoreExams: ImmutableList = persistentListOf(), ) diff --git a/feature/setting/src/main/res/values/strings.xml b/feature/setting/src/main/res/values/strings.xml index 8af0142f4..0bb093744 100644 --- a/feature/setting/src/main/res/values/strings.xml +++ b/feature/setting/src/main/res/values/strings.xml @@ -22,12 +22,17 @@ 앗, 정말 로그아웃 하시겠어요? 정말 탈퇴하시겠어요? 안 할래요 + 차단유저 목록 + 차단시험 목록 + + 사용자 설정 로그인 계정 이메일 문의처 인스타그램 취소 + 기타 정말 탈퇴하시겠어요?\n%s님과 이별하려니 너무 아쉬워요.. 계정을 삭제하면 개인정보 및 출제한 덕력고사, 관심 등 모든 활동 정보가 삭제돼요. @@ -38,7 +43,6 @@ 자주 사용하지 않는 앱이에요. 잦은 오류가 발생해서 쓸 수가 없어요. 새 계정으로 가입하려구요. - 기타 어떤 점이 불편하셨나요?\n다시 덕키를 방문했을 때, 더 나은 덕키가 될 수 있도록 노력할게요! 조금 더 이용하기 @@ -51,6 +55,9 @@ 명예의 전당 공지사항 + 차단한 유저가 없습니다. + 차단한 덕력고사가 없습니다. + 내가 출제한 덕력고사 및 문제에 대한 알림 내 덕력고사에 다른 유저가 좋아요를 눌렀을 때의 알림 다른 유저가 나를 팔로우했을 때의 알림 diff --git a/feature/solve-problem/build.gradle.kts b/feature/solve-problem/build.gradle.kts index 6d70e9dbd..f00e1e72d 100644 --- a/feature/solve-problem/build.gradle.kts +++ b/feature/solve-problem/build.gradle.kts @@ -26,12 +26,12 @@ dependencies { projects.common.kotlin, projects.common.compose, projects.common.android, + libs.kotlin.collections.immutable, libs.orbit.viewmodel, libs.orbit.compose, libs.ktx.lifecycle.runtime, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold - libs.quack.ui.components, libs.quack.v2.ui, libs.firebase.crashlytics, libs.exoplayer.core, diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/SolveProblemActivity.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/SolveProblemActivity.kt index 019dbca18..7a06b2a67 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/SolveProblemActivity.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/SolveProblemActivity.kt @@ -35,6 +35,7 @@ import team.duckie.app.android.common.android.ui.finishWithAnimation import team.duckie.app.android.common.compose.moveNextPage import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade +import team.duckie.app.android.common.compose.util.addFocusCleaner import team.duckie.app.android.domain.quiz.usecase.SubmitQuizUseCase import team.duckie.app.android.feature.solve.problem.common.LoadingIndicator import team.duckie.app.android.feature.solve.problem.screen.QuizScreen @@ -42,8 +43,8 @@ import team.duckie.app.android.feature.solve.problem.screen.SolveProblemScreen import team.duckie.app.android.feature.solve.problem.viewmodel.SolveProblemViewModel import team.duckie.app.android.feature.solve.problem.viewmodel.sideeffect.SolveProblemSideEffect import team.duckie.app.android.navigator.feature.examresult.ExamResultNavigator -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint @@ -60,7 +61,9 @@ class SolveProblemActivity : BaseActivity() { QuackTheme { val state by viewModel.collectAsState() val progress by viewModel.timerCount.collectAsStateWithLifecycle() - val pagerState = rememberPagerState() + val pagerState = rememberPagerState( + pageCount = { state.totalPage }, + ) LaunchedEffect(viewModel.container.sideEffectFlow) { viewModel.container.sideEffectFlow.collect { sideEffect -> @@ -74,7 +77,8 @@ class SolveProblemActivity : BaseActivity() { QuackCrossfade( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) + .addFocusCleaner() .systemBarsPadding() .navigationBarsPadding() .imePadding(), diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/AnswerSection.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/AnswerSection.kt index e7c839837..63d6b3c0e 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/AnswerSection.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/AnswerSection.kt @@ -18,10 +18,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import team.duckie.app.android.common.compose.ui.DuckieGridLayout +import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.feature.solve.problem.answer.choice.ImageAnswerBox import team.duckie.app.android.feature.solve.problem.answer.choice.TextAnswerBox @@ -30,6 +33,10 @@ import team.duckie.app.android.feature.solve.problem.viewmodel.state.InputAnswer private val HorizontalPadding = PaddingValues(horizontal = 16.dp) +internal object TextFieldMargin { + val Top = 16.dp +} + @Composable internal fun ColumnScope.AnswerSection( pageIndex: Int, @@ -38,11 +45,14 @@ internal fun ColumnScope.AnswerSection( updateInputAnswers: (page: Int, inputAnswer: InputAnswer) -> Unit, requestFocus: Boolean, keyboardController: SoftwareKeyboardController?, + onShortAnswerSizeChanged: (IntSize) -> Unit, ) { when (answer) { is Answer.Choice -> { Column( - modifier = Modifier.padding(paddingValues = HorizontalPadding), + modifier = Modifier + .padding(vertical = 24.dp) + .padding(paddingValues = HorizontalPadding), verticalArrangement = Arrangement.spacedBy(space = 12.dp), ) { answer.choices.forEachIndexed { index, choice -> @@ -91,7 +101,9 @@ internal fun ColumnScope.AnswerSection( } is Answer.Short -> { + Spacer(space = TextFieldMargin.Top) ShortAnswerForm( + modifier = Modifier.onSizeChanged(onShortAnswerSizeChanged), answer = answer.correctAnswer, onTextChanged = { inputText -> updateInputAnswers(pageIndex, InputAnswer(0, inputText)) diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/choice/AnswerBox.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/choice/AnswerBox.kt index ea062c967..640b36aca 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/choice/AnswerBox.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/choice/AnswerBox.kt @@ -7,7 +7,10 @@ package team.duckie.app.android.feature.solve.problem.answer.choice +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -20,17 +23,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.textstyle.QuackTextStyle -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Check +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText @Composable internal fun TextAnswerBox( @@ -103,7 +109,7 @@ private fun TextAndCheck( ) { QuackText( text = text, - style = QuackTextStyle.Body1.change( + typography = QuackTypography.Body1.change( color = when (selected) { true -> QuackColor.DuckieOrange else -> QuackColor.Black @@ -111,11 +117,11 @@ private fun TextAndCheck( textAlign = TextAlign.Start, ), ) - QuackAnimatedVisibility(visible = selected) { - QuackImage( - src = QuackIcon.Check, + AnimatedVisibility(visible = selected) { + QuackIcon( + icon = QuackIcon.Outlined.Check, tint = QuackColor.DuckieOrange, - size = DpSize(all = 18.dp), + size = 18.dp, ) } } @@ -128,12 +134,17 @@ private fun GraySurface( onClick: () -> Unit, content: @Composable (BoxScope.() -> Unit), ) { - QuackSurface( - modifier = modifier.fillMaxWidth(), - backgroundColor = QuackColor.Gray4, - border = setBoxBorder(selected = selected), - shape = RoundedCornerShape(size = 8.dp), - onClick = onClick, + val shape = RoundedCornerShape(size = 8.dp) + Box( + modifier = modifier + .fillMaxWidth() + .clip(shape = shape) + .background(color = QuackColor.Gray4.value) + .quackClickable(onClick = onClick) + .quackBorder( + border = setBoxBorder(selected = selected), + shape = shape, + ), content = content, ) } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/shortanswer/ShortAnswerForm.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/shortanswer/ShortAnswerForm.kt index 432e7b893..eeb873195 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/shortanswer/ShortAnswerForm.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/shortanswer/ShortAnswerForm.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.unit.dp import team.duckie.quackquack.material.QuackBorder import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.quackBorder -import team.duckie.quackquack.ui.component.QuackBody1 +import team.duckie.quackquack.ui.sugar.QuackBody1 @OptIn(ExperimentalLayoutApi::class) @Composable diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/BottomBar.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/BottomBar.kt index 62bf17a89..94288b737 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/BottomBar.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/BottomBar.kt @@ -7,6 +7,7 @@ package team.duckie.app.android.feature.solve.problem.common +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,16 +18,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSurface import team.duckie.app.android.feature.solve.problem.R -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackDivider -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.ui.QuackText @Composable internal fun ButtonBottomBar( @@ -38,7 +38,7 @@ internal fun ButtonBottomBar( Column( modifier = modifier, ) { - QuackDivider() + QuackMaxWidthDivider() Row( modifier = Modifier .fillMaxWidth() @@ -49,7 +49,10 @@ internal fun ButtonBottomBar( horizontalArrangement = Arrangement.SpaceBetween, ) { Spacer(modifier = Modifier) - QuackAnimatedContent(targetState = isLastPage) { + AnimatedContent( + targetState = isLastPage, + label = "AnimatedContent", + ) { when (it) { false -> MediumButton( text = stringResource(id = R.string.next), @@ -86,7 +89,7 @@ internal fun DoubleButtonBottomBar( Column( modifier = modifier, ) { - QuackDivider() + QuackMaxWidthDivider() Row( modifier = Modifier .fillMaxWidth() @@ -96,7 +99,10 @@ internal fun DoubleButtonBottomBar( ), horizontalArrangement = Arrangement.SpaceBetween, ) { - QuackAnimatedContent(targetState = isFirstPage) { + AnimatedContent( + targetState = isFirstPage, + label = "AnimatedContent", + ) { when (it) { true -> { Spacer(modifier = Modifier) @@ -111,7 +117,10 @@ internal fun DoubleButtonBottomBar( } } - QuackAnimatedContent(targetState = isLastPage) { + AnimatedContent( + targetState = isLastPage, + label = "AnimatedContent", + ) { when (it) { false -> MediumButton( text = stringResource(id = R.string.next), @@ -141,11 +150,13 @@ private fun MediumButton( textColor: QuackColor = textColorFor(enabled), ) { QuackSurface( - modifier = Modifier, - backgroundColor = backgroundColor, - border = border, - shape = RoundedCornerShape(size = 8.dp), + modifier = Modifier.quackBorder( + shape = RoundedCornerShape(8.dp), + border = border, + ), + shape = RoundedCornerShape(8.dp), onClick = onClickFor(enabled, onClick), + backgroundColor = backgroundColor, ) { QuackText( modifier = Modifier.padding( @@ -153,11 +164,9 @@ private fun MediumButton( horizontal = 12.dp, ), text = text, - style = QuackTextStyle.Body1.change( + typography = QuackTypography.Body1.change( color = textColor, - textAlign = TextAlign.Center, ), - singleLine = true, ) } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/FlexibleSubjectiveQuestionSection.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/FlexibleSubjectiveQuestionSection.kt deleted file mode 100644 index a54d43dd4..000000000 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/FlexibleSubjectiveQuestionSection.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Designed and developed by Duckie Team, 2022 - * - * Licensed under the MIT. - * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE - */ - -@file:OptIn(ExperimentalComposeUiApi::class) - -package team.duckie.app.android.feature.solve.problem.common - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.SoftwareKeyboardController -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.kotlin.AllowMagicNumber -import team.duckie.app.android.domain.exam.model.Problem -import team.duckie.app.android.domain.exam.model.Question -import team.duckie.app.android.feature.solve.problem.answer.shortanswer.ShortAnswerForm -import team.duckie.app.android.feature.solve.problem.viewmodel.state.InputAnswer -import team.duckie.quackquack.material.QuackColor -import team.duckie.quackquack.ui.QuackImage -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.sugar.QuackHeadLine2 - -@AllowMagicNumber("to get flexible image height") -@NonRestartableComposable -@Composable -private fun getFlexibleImageHeight(): Dp { - val configuration = LocalConfiguration.current - return ((configuration.screenWidthDp / 4) * 3).dp -} - -private object TextFieldMargin { - val Top = 24.dp - val Bottom = 16.dp - val Vertical get() = Top + Bottom -} - -/** - * 키보드 상태에 따라서 이미지의 높이가 유동적으로 변하는 주관식 문제를 위한 섹션 - */ -@Composable -fun FlexibleSubjectiveQuestionSection( - problem: Problem, - pageIndex: Int, - updateInputAnswers: (page: Int, inputAnswer: InputAnswer) -> Unit, - requestFocus: Boolean, - keyboardController: SoftwareKeyboardController?, -) { - val density = LocalDensity.current - val question = problem.question as Question.Image - val flexibleImageHeight = getFlexibleImageHeight() - var textFieldHeight by remember { mutableStateOf(Dp.Unspecified) } - - Column( - modifier = Modifier - .fillMaxSize() - .imePadding() - .quackClickable( - rippleEnabled = false, - ) { - keyboardController?.hide() - }, - ) { - Spacer(space = 16.dp) - QuackHeadLine2( - modifier = Modifier.padding(horizontal = 16.dp), - text = "${pageIndex + 1}. ${question.text}", - ) - Spacer(space = 12.dp) - BoxWithConstraints { - val actualHeight = - if (maxHeight - textFieldHeight - TextFieldMargin.Vertical >= flexibleImageHeight) { - flexibleImageHeight - } else { - maxHeight - textFieldHeight - TextFieldMargin.Vertical - } - - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = actualHeight) - .padding(horizontal = 16.dp) - .background( - color = QuackColor.Black.value, - shape = RoundedCornerShape(16.dp), - ), - ) { - QuackImage( - modifier = Modifier.fillMaxSize(), - src = question.imageUrl, - contentScale = ContentScale.Fit, - ) - } - } - Spacer(space = TextFieldMargin.Top) - ShortAnswerForm( - modifier = Modifier.onSizeChanged { - with(density) { - textFieldHeight = it.height.toDp() - } - }, - answer = problem.correctAnswer ?: "", - onTextChanged = { inputText -> - updateInputAnswers(pageIndex, InputAnswer(0, inputText)) - }, - requestFocus = requestFocus, - keyboardController = keyboardController, - ) - Spacer(space = TextFieldMargin.Bottom) - } -} diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/LoadingIndicator.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/LoadingIndicator.kt index 2daad1fae..93b4d4d09 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/LoadingIndicator.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/LoadingIndicator.kt @@ -13,15 +13,14 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor -// TODO(EvergreenTree97): QuackLoadingIndicator로 통합 필요 @Composable internal fun LoadingIndicator() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(color = QuackColor.DuckieOrange.composeColor) + CircularProgressIndicator(color = QuackColor.DuckieOrange.value) } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/TopBar.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/TopBar.kt index 93bc55b80..af134ace4 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/TopBar.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/TopBar.kt @@ -14,20 +14,21 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import team.duckie.app.android.common.compose.ui.icon.v1.Clock import team.duckie.app.android.common.compose.ui.LinearProgressBar -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackSubtitle2 -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.app.android.common.compose.ui.icon.v2.Clock +import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackSubtitle2 @Composable internal fun CloseAndPageTopBar( @@ -38,7 +39,7 @@ internal fun CloseAndPageTopBar( ) { QuackTopAppBar( modifier = modifier, - leadingIcon = QuackIcon.Close, + leadingIcon = QuackIcon.Outlined.Close, onLeadingIconClick = onCloseClick, trailingContent = { PageInfo( @@ -58,25 +59,25 @@ internal fun TimerTopBar( Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(bottom = 16.dp), ) { QuackTopAppBar( - leadingIcon = QuackIcon.Close, + leadingIcon = QuackIcon.Outlined.Close, onLeadingIconClick = onCloseClick, ) Box( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), contentAlignment = Alignment.CenterStart, ) { LinearProgressBar( progress = progress(), ) - QuackImage( - modifier = Modifier - .clip(CircleShape) - .background(QuackColor.White.composeColor), - src = QuackIcon.Clock, - size = DpSize(all = 16.dp), + QuackIcon( + modifier = Modifier.background(QuackColor.White.value), + icon = QuackIcon.Clock, + size = 16.dp, ) } } @@ -92,13 +93,13 @@ private fun PageInfo( horizontalArrangement = Arrangement.spacedBy(space = 2.dp), ) { QuackSubtitle2(text = currentPage.toString()) - QuackSubtitle2( + QuackText( text = " / ", - color = QuackColor.Gray2, + typography = QuackTypography.Subtitle2.change(color = QuackColor.Gray2), ) - QuackSubtitle2( + QuackText( text = totalPage.toString(), - color = QuackColor.Gray2, + typography = QuackTypography.Subtitle2.change(color = QuackColor.Gray2), ) } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/QuestionSection.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/QuestionSection.kt index e5f423862..2087e6c32 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/QuestionSection.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/QuestionSection.kt @@ -7,29 +7,38 @@ package team.duckie.app.android.feature.solve.problem.question +import androidx.compose.foundation.background import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import team.duckie.app.android.common.compose.GetHeightRatioW328H240 import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.domain.exam.model.Question import team.duckie.app.android.feature.solve.problem.question.audio.AudioPlayer +import team.duckie.app.android.feature.solve.problem.question.image.FlexibleImageBox import team.duckie.app.android.feature.solve.problem.question.image.ImageBox import team.duckie.app.android.feature.solve.problem.question.video.VideoPlayer -import team.duckie.quackquack.ui.component.QuackHeadLine2 +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.ui.sugar.QuackHeadLine2 -private val HorizontalPadding = PaddingValues(horizontal = 16.dp) +private val HorizontalPadding = 16.dp @Composable internal fun ColumnScope.QuestionSection( page: Int, question: Question, isImageChoice: Boolean, + isRequireFlexibleImage: Boolean, + spaceImageToKeyboard: Dp, + onImageLoading: (Boolean) -> Unit, + onImageSuccess: (Boolean) -> Unit, + isImageLoading: Boolean, ) { val modifier = if (isImageChoice) { Modifier @@ -39,22 +48,43 @@ internal fun ColumnScope.QuestionSection( Modifier.weight(GetHeightRatioW328H240) } QuackHeadLine2( + modifier = Modifier.padding(horizontal = HorizontalPadding), text = "${page + 1}. ${question.text}", - padding = HorizontalPadding, ) when (question) { is Question.Text -> {} is Question.Image -> { - Spacer(space = 16.dp) - ImageBox( - modifier = modifier, - url = question.imageUrl, - ) + Spacer(space = 12.dp) + if (isRequireFlexibleImage) { + FlexibleImageBox( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + spaceImageToKeyboard = spaceImageToKeyboard, + url = question.imageUrl, + onImageLoading = onImageLoading, + onImageSuccess = onImageSuccess, + isImageLoading = isImageLoading, + ) + } else { + ImageBox( + modifier = modifier + .padding(horizontal = 16.dp) + .background( + color = QuackColor.Gray4.value, + shape = RoundedCornerShape(8.dp), + ), + url = question.imageUrl, + onImageLoading = onImageLoading, + onImageSuccess = onImageSuccess, + isImageLoading = isImageLoading, + ) + } } is Question.Audio -> { AudioPlayer( - modifier = Modifier.padding(paddingValues = HorizontalPadding), + modifier = Modifier.padding(horizontal = HorizontalPadding), url = question.audioUrl, ) } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/audio/Controller.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/audio/Controller.kt index 534ed224a..9370d1b57 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/audio/Controller.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/audio/Controller.kt @@ -5,29 +5,25 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.solve.problem.question.audio import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.ui.QuackButton +import team.duckie.quackquack.ui.QuackButtonStyle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun AudioController( @@ -87,23 +83,10 @@ private fun LargeButton( text: String, onClick: () -> Unit, ) { - QuackSurface( - modifier = modifier.sizeIn( - minWidth = 82.dp, - minHeight = 40.dp, - ), - backgroundColor = QuackColor.White, - border = QuackBorder( - color = QuackColor.Gray3, - ), - shape = RoundedCornerShape(size = 8.dp), + QuackButton( + modifier = modifier, + text = text, + style = QuackButtonStyle.SecondaryRoundSmall, onClick = onClick, - ) { - QuackText( - modifier = Modifier.padding(all = 10.dp), - text = text, - style = QuackTextStyle.Subtitle, - singleLine = true, - ) - } + ) } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/image/ImageBox.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/image/ImageBox.kt index f70859489..50de3510d 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/image/ImageBox.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/image/ImageBox.kt @@ -7,37 +7,117 @@ package team.duckie.app.android.feature.solve.problem.question.image +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.feature.solve.problem.R import team.duckie.quackquack.material.QuackColor -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText + +@Suppress("MagicNumber") +@NonRestartableComposable +@Composable +private fun getFlexibleImageHeight(): Dp { + val configuration = LocalConfiguration.current + return ((configuration.screenWidthDp / 4) * 3).dp +} + +@Composable +internal fun FlexibleImageBox( + modifier: Modifier = Modifier, + spaceImageToKeyboard: Dp, + url: String, + onImageLoading: (Boolean) -> Unit, + onImageSuccess: (Boolean) -> Unit, + isImageLoading: Boolean, +) { + val flexibleImageHeight = getFlexibleImageHeight() + BoxWithConstraints(modifier = modifier) { + val actualHeight = + if (maxHeight - spaceImageToKeyboard >= flexibleImageHeight) { + flexibleImageHeight + } else { + maxHeight - spaceImageToKeyboard + } + + ImageBox( + modifier = Modifier + .fillMaxWidth() + .height(height = actualHeight) + .background( + color = QuackColor.Gray4.value, + shape = RoundedCornerShape(8.dp), + ), + url = url, + onImageLoading = onImageLoading, + onImageSuccess = onImageSuccess, + isImageLoading = isImageLoading, + ) + } +} @Composable internal fun ImageBox( modifier: Modifier = Modifier, url: String, + onImageLoading: (Boolean) -> Unit, + onImageSuccess: (Boolean) -> Unit, + isImageLoading: Boolean, ) { - Box( - modifier = modifier - .padding(horizontal = 16.dp) - .background( - color = QuackColor.Black.value, - shape = RoundedCornerShape(8.dp), - ), - contentAlignment = Alignment.Center, - ) { - QuackImage( - modifier = Modifier.fillMaxSize(), - src = url, - contentScale = ContentScale.Fit, + Box(modifier = modifier) { + AsyncImage( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .fillMaxSize(), + onLoading = { + onImageLoading(true) + }, + onSuccess = { + onImageSuccess(false) + }, + model = url, + contentDescription = "", ) + AnimatedVisibility( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(QuackColor.Black.value.copy(alpha = 0.5f)), + visible = isImageLoading, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + QuackText( + text = stringResource(id = R.string.loading_image), + typography = QuackTypography.Body1.change( + color = QuackColor.White, + ), + ) + Spacer(space = 12.dp) + CircularProgressIndicator(color = QuackColor.DuckieOrange.value) + } + } } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Controller.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Controller.kt index 45bad3b16..df398e859 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Controller.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Controller.kt @@ -7,13 +7,13 @@ package team.duckie.app.android.feature.solve.problem.question.video +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable @@ -31,11 +31,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import team.duckie.app.android.feature.solve.problem.R -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText import java.util.Locale import java.util.concurrent.TimeUnit @@ -51,7 +51,7 @@ internal fun VideoController( onTimeChanged: (Float) -> Unit, ) { var sliderHeight by remember { mutableStateOf(0) } - QuackAnimatedVisibility( + AnimatedVisibility( modifier = modifier, visible = isVisible(), ) { @@ -63,22 +63,23 @@ internal fun VideoController( ) Column( modifier = Modifier - .align(alignment = Alignment.BottomCenter) - .offset { - IntOffset( - x = 0, - y = sliderHeight / 2, - ) - }, + .align(alignment = Alignment.BottomCenter) + .offset { + IntOffset( + x = 0, + y = sliderHeight / 2, + ) + }, ) { - QuackBody3( - modifier = Modifier.padding(start = 12.dp), + QuackText( text = stringResource( id = R.string.current_between_total, currentTime().formatMinSec(), totalTime().formatMinSec(), ), - color = QuackColor.Gray3, + typography = QuackTypography.Body3.change( + color = QuackColor.Gray3, + ), ) VideoSlider( modifier = Modifier @@ -112,7 +113,7 @@ internal fun InteractionButton( modifier = modifier .fillMaxSize() .alpha(alpha = 0.2f) - .background(color = QuackColor.Black.composeColor), + .background(color = QuackColor.Black.value), ) QuackImage( modifier = modifier, diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Slider.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Slider.kt index 97d17a96e..be9a9a2ac 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Slider.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Slider.kt @@ -14,20 +14,20 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import team.duckie.app.android.common.compose.rememberNoRippleInteractionSource -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor @Composable internal fun primarySliderColors() = SliderDefaults.colors( - thumbColor = QuackColor.DuckieOrange.composeColor, - activeTrackColor = QuackColor.DuckieOrange.composeColor, + thumbColor = QuackColor.DuckieOrange.value, + activeTrackColor = QuackColor.DuckieOrange.value, inactiveTrackColor = Color.Transparent, ) @Composable internal fun bufferSliderColors() = SliderDefaults.colors( disabledThumbColor = Color.Transparent, - disabledActiveTrackColor = QuackColor.Gray2.composeColor.copy(alpha = 0.5f), - disabledInactiveTrackColor = QuackColor.Gray3.composeColor, + disabledActiveTrackColor = QuackColor.Gray2.value.copy(alpha = 0.5f), + disabledInactiveTrackColor = QuackColor.Gray3.value, ) @Composable diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/QuizScreen.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/QuizScreen.kt index 1be257b65..ad4281387 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/QuizScreen.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/QuizScreen.kt @@ -7,8 +7,6 @@ @file:OptIn( ExperimentalFoundationApi::class, - ExperimentalComposeUiApi::class, - ExperimentalFoundationApi::class, ) package team.duckie.app.android.feature.solve.problem.screen @@ -29,17 +27,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import team.duckie.app.android.common.compose.isCurrentPage +import team.duckie.app.android.common.compose.isTargetPage import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe @@ -47,8 +47,8 @@ import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.Problem.Companion.isSubjective import team.duckie.app.android.feature.solve.problem.R import team.duckie.app.android.feature.solve.problem.answer.AnswerSection +import team.duckie.app.android.feature.solve.problem.answer.TextFieldMargin import team.duckie.app.android.feature.solve.problem.common.ButtonBottomBar -import team.duckie.app.android.feature.solve.problem.common.FlexibleSubjectiveQuestionSection import team.duckie.app.android.feature.solve.problem.common.TimerTopBar import team.duckie.app.android.feature.solve.problem.common.verticalScrollModifierAsCondition import team.duckie.app.android.feature.solve.problem.question.QuestionSection @@ -86,10 +86,6 @@ internal fun QuizScreen( ) } - LaunchedEffect(pagerState.targetPage) { - startTimer(state.time.toFloat()) - } - LaunchedEffect(timeOver) { if (timeOver) { onNextPage( @@ -132,6 +128,7 @@ internal fun QuizScreen( updateInputAnswers = { index, answer -> inputAnswers[index] = answer }, + startTimer = startTimer, ) ButtonBottomBar( modifier = Modifier @@ -174,70 +171,89 @@ private fun ContentSection( state: SolveProblemState, inputAnswers: ImmutableList, updateInputAnswers: (page: Int, inputAnswer: InputAnswer) -> Unit, + startTimer: (Float) -> Unit, ) { val keyboardController = LocalSoftwareKeyboardController.current + var textFieldHeight by remember { mutableStateOf(Dp.Unspecified) } + val density = LocalDensity.current + val isImageLoading = remember { + mutableStateListOf( + elements = Array( + size = state.quizProblems.size, + init = { true }, + ), + ) + } HorizontalPager( modifier = modifier, - pageCount = state.totalPage, state = pagerState, userScrollEnabled = false, + beyondBoundsPageCount = 3, ) { pageIndex -> val problem = state.quizProblems[pageIndex] - val requestFocus by remember(key1 = pagerState.currentPage) { + val isCurrentPage by remember(pagerState.currentPage) { derivedStateOf { pagerState.isCurrentPage(pageIndex) } } - LaunchedEffect(key1 = requestFocus) { + LaunchedEffect(pagerState.targetPage, isImageLoading[pageIndex]) { + if (isImageLoading[pageIndex].not() && pagerState.isTargetPage(pageIndex)) { + startTimer(state.time.toFloat()) + } + } + + LaunchedEffect(key1 = isCurrentPage) { if (!problem.isSubjective()) { keyboardController?.hide() } } val isImageChoice = problem.answer?.isImageChoice == true + val isRequireFlexibleImage = problem.isSubjective() && problem.question.isImage() - when { - // for keyboard flexible image height - problem.isSubjective() && problem.question.isImage() -> FlexibleSubjectiveQuestionSection( - problem = problem, + Column( + modifier = Modifier + .verticalScrollModifierAsCondition(isImageChoice) + .fillMaxSize(), + ) { + QuestionSection( + page = pageIndex, + question = problem.question, + isImageChoice = isImageChoice, + isRequireFlexibleImage = isRequireFlexibleImage, + spaceImageToKeyboard = textFieldHeight + TextFieldMargin.Top, + onImageLoading = { + isImageLoading[pageIndex] = it + }, + onImageSuccess = { + isImageLoading[pageIndex] = it + }, + isImageLoading = isImageLoading[pageIndex], + ) + val answer = problem.answer + AnswerSection( pageIndex = pageIndex, + answer = when (answer) { + is Answer.Short -> Answer.Short( + problem.correctAnswer + ?: duckieResponseFieldNpe("null 이 되면 안됩니다."), + ) + + is Answer.Choice, is Answer.ImageChoice -> answer + else -> duckieResponseFieldNpe("해당 분기로 빠질 수 없는 AnswerType 입니다.") + }, + inputAnswers = inputAnswers, updateInputAnswers = updateInputAnswers, - requestFocus = requestFocus, + requestFocus = isCurrentPage, keyboardController = keyboardController, + onShortAnswerSizeChanged = { + textFieldHeight = with(density) { + it.height.toDp() + } + }, ) - - else -> Column( - modifier = Modifier - .verticalScrollModifierAsCondition(isImageChoice) - .fillMaxSize(), - ) { - Spacer(space = 16.dp) - QuestionSection( - page = pageIndex, - question = problem.question, - isImageChoice = isImageChoice, - ) - Spacer(space = 24.dp) - val answer = problem.answer - AnswerSection( - pageIndex = pageIndex, - answer = when (answer) { - is Answer.Short -> Answer.Short( - problem.correctAnswer - ?: duckieResponseFieldNpe("null 이 되면 안됩니다."), - ) - - is Answer.Choice, is Answer.ImageChoice -> answer - else -> duckieResponseFieldNpe("해당 분기로 빠질 수 없는 AnswerType 입니다.") - }, - inputAnswers = inputAnswers, - updateInputAnswers = updateInputAnswers, - requestFocus = requestFocus, - keyboardController = keyboardController, - ) - Spacer(space = 16.dp) - } + Spacer(space = 16.dp) } } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/SolveProblemScreen.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/SolveProblemScreen.kt index 4ba491ab7..a023989a0 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/SolveProblemScreen.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/SolveProblemScreen.kt @@ -13,6 +13,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState @@ -24,13 +25,16 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -46,19 +50,19 @@ import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.Problem.Companion.isSubjective import team.duckie.app.android.feature.solve.problem.R import team.duckie.app.android.feature.solve.problem.answer.AnswerSection +import team.duckie.app.android.feature.solve.problem.answer.TextFieldMargin import team.duckie.app.android.feature.solve.problem.common.CloseAndPageTopBar import team.duckie.app.android.feature.solve.problem.common.DoubleButtonBottomBar -import team.duckie.app.android.feature.solve.problem.common.FlexibleSubjectiveQuestionSection import team.duckie.app.android.feature.solve.problem.common.verticalScrollModifierAsCondition import team.duckie.app.android.feature.solve.problem.question.QuestionSection import team.duckie.app.android.feature.solve.problem.viewmodel.state.InputAnswer import team.duckie.app.android.feature.solve.problem.viewmodel.state.SolveProblemState +import team.duckie.quackquack.material.quackClickable private const val SolveProblemTopAppBarLayoutId = "SolveProblemTopAppBar" private const val SolveProblemContentLayoutId = "SolveProblemContent" private const val SolveProblemBottomBarLayoutId = "SolveProblemBottomBar" -// 6번호 @Composable internal fun SolveProblemScreen( state: SolveProblemState, @@ -66,8 +70,6 @@ internal fun SolveProblemScreen( finishExam: (List) -> Unit, pagerState: PagerState, ) { - val totalPage = remember { state.totalPage } - val coroutineScope = rememberCoroutineScope() var examExitDialogVisible by remember { mutableStateOf(false) } var examSubmitDialogVisible by remember { mutableStateOf(false) } @@ -116,13 +118,14 @@ internal fun SolveProblemScreen( CloseAndPageTopBar( modifier = Modifier .layoutId(SolveProblemTopAppBarLayoutId) + .fillMaxWidth() .padding(start = 12.dp) .padding(end = 16.dp), onCloseClick = { examExitDialogVisible = true }, currentPage = pagerState.currentPage + 1, - totalPage = totalPage, + totalPage = state.totalPage, ) ContentSection( modifier = Modifier.layoutId(SolveProblemContentLayoutId), @@ -136,7 +139,7 @@ internal fun SolveProblemScreen( DoubleButtonBottomBar( modifier = Modifier.layoutId(SolveProblemBottomBarLayoutId), isFirstPage = pagerState.currentPage == 0, - isLastPage = pagerState.currentPage == totalPage - 1, + isLastPage = pagerState.currentPage == state.totalPage - 1, onLeftButtonClick = { coroutineScope.launch { pagerState.movePrevPage() @@ -144,7 +147,7 @@ internal fun SolveProblemScreen( }, onRightButtonClick = { coroutineScope.launch { - val maximumPage = totalPage - 1 + val maximumPage = state.totalPage - 1 if (pagerState.currentPage == maximumPage) { examSubmitDialogVisible = true } else { @@ -171,10 +174,11 @@ private fun ContentSection( updateInputAnswers: (Int, InputAnswer) -> Unit, ) { val keyboardController = LocalSoftwareKeyboardController.current + var textFieldHeight by remember { mutableStateOf(Dp.Unspecified) } + val density = LocalDensity.current HorizontalPager( modifier = modifier, - pageCount = state.totalPage, state = pagerState, ) { pageIndex -> val problem = state.problems[pageIndex].problem @@ -185,6 +189,8 @@ private fun ContentSection( } } + var isImageLoading by rememberSaveable { mutableStateOf(true) } + LaunchedEffect(key1 = requestFocus) { if (!problem.isSubjective()) { keyboardController?.hide() @@ -192,48 +198,50 @@ private fun ContentSection( } val isImageChoice = problem.answer?.isImageChoice == true - - when { - // for keyboard flexible image height - problem.isSubjective() && problem.question.isImage() -> FlexibleSubjectiveQuestionSection( - problem = problem, + val isRequireFlexibleImage = problem.isSubjective() && problem.question.isImage() + Column( + modifier = Modifier + .verticalScrollModifierAsCondition(isImageChoice) + .fillMaxSize() + .quackClickable( + rippleEnabled = false, + ) { + keyboardController?.hide() + }, + ) { + Spacer(space = 16.dp) + QuestionSection( + page = pageIndex, + question = problem.question, + isRequireFlexibleImage = isRequireFlexibleImage, + spaceImageToKeyboard = textFieldHeight + TextFieldMargin.Top, + onImageLoading = { isImageLoading = it }, + onImageSuccess = { isImageLoading = it }, + isImageLoading = isImageLoading, + isImageChoice = isImageChoice, + ) + val answer = problem.answer + AnswerSection( pageIndex = pageIndex, + answer = when (answer) { + is Answer.Short -> Answer.Short( + problem.correctAnswer + ?: duckieResponseFieldNpe("null 이 되면 안됩니다."), + ) + + is Answer.Choice, is Answer.ImageChoice -> answer + else -> duckieResponseFieldNpe("해당 분기로 빠질 수 없는 AnswerType 입니다.") + }, + inputAnswers = inputAnswers, updateInputAnswers = updateInputAnswers, requestFocus = requestFocus, keyboardController = keyboardController, + onShortAnswerSizeChanged = { + textFieldHeight = with(density) { + it.height.toDp() + } + }, ) - - else -> Column( - modifier = Modifier - .verticalScrollModifierAsCondition(isImageChoice) - .fillMaxSize(), - ) { - Spacer(space = 16.dp) - QuestionSection( - page = pageIndex, - question = problem.question, - isImageChoice = isImageChoice, - ) - Spacer(space = 24.dp) - val answer = problem.answer - AnswerSection( - pageIndex = pageIndex, - answer = when (answer) { - is Answer.Short -> Answer.Short( - problem.correctAnswer - ?: duckieResponseFieldNpe("null 이 되면 안됩니다."), - ) - - is Answer.Choice, is Answer.ImageChoice -> answer - else -> duckieResponseFieldNpe("해당 분기로 빠질 수 없는 AnswerType 입니다.") - }, - inputAnswers = inputAnswers, - updateInputAnswers = updateInputAnswers, - requestFocus = requestFocus, - keyboardController = keyboardController, - ) - Spacer(space = 16.dp) - } } } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/viewmodel/SolveProblemViewModel.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/viewmodel/SolveProblemViewModel.kt index e26e5ef4f..c20a0d0d6 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/viewmodel/SolveProblemViewModel.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/viewmodel/SolveProblemViewModel.kt @@ -56,7 +56,7 @@ internal class SolveProblemViewModel @Inject constructor( problemTimer.start(time) } - fun stopTimer() { + private fun stopTimer() { problemTimer.stop() } @@ -110,7 +110,9 @@ internal class SolveProblemViewModel @Inject constructor( reduce { state.copy(isProblemsLoading = true, isError = false) } getQuizUseCase(examId = examId).onSuccess { quizResult -> - val quizProblems = quizResult.exam.problems + val quizProblems = quizResult.exam.problems.also { + problemTimer.setTotalTime(quizResult.exam.timer?.toFloat() ?: 0f) + } if (quizProblems == null) { stopExam() } else { @@ -140,7 +142,6 @@ internal class SolveProblemViewModel @Inject constructor( ) = intent { val correctAnswer = state.quizProblems[pageIndex].correctAnswer ?: throw DuckieClientLogicProblemException(code = CORRECT_ANSWER_IS_NULL) - state.quizProblems[pageIndex].answer if (correctAnswer.replace(" ", "").lowercase() != inputAnswer.answer.replace(" ", "") .lowercase() ) { diff --git a/feature/solve-problem/src/main/res/values/strings.xml b/feature/solve-problem/src/main/res/values/strings.xml index 7c5f975cd..9b95b83f7 100644 --- a/feature/solve-problem/src/main/res/values/strings.xml +++ b/feature/solve-problem/src/main/res/values/strings.xml @@ -19,4 +19,5 @@ 답안을 제출하시겠어요? 아직 풀지 않은 문제가 있는지 확인해주세요. 띄어쓰기 포함 %s자 + 이미지를 불러오고 있어요 :) diff --git a/feature/start-exam/build.gradle.kts b/feature/start-exam/build.gradle.kts index d4690c190..81a6fecd3 100644 --- a/feature/start-exam/build.gradle.kts +++ b/feature/start-exam/build.gradle.kts @@ -28,7 +28,7 @@ dependencies { projects.common.compose, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, + libs.quack.v2.ui, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for CircularProgressIndicator libs.firebase.crashlytics, diff --git a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamActivity.kt b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamActivity.kt index 9ad65647f..812ac7adc 100644 --- a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamActivity.kt +++ b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamActivity.kt @@ -21,11 +21,12 @@ import org.orbitmvi.orbit.viewmodel.observe import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.common.android.ui.const.Extras import team.duckie.app.android.common.android.ui.finishWithAnimation +import team.duckie.app.android.common.compose.util.addFocusCleaner import team.duckie.app.android.feature.start.exam.viewmodel.StartExamSideEffect import team.duckie.app.android.feature.start.exam.viewmodel.StartExamViewModel import team.duckie.app.android.navigator.feature.solveproblem.SolveProblemNavigator -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint @@ -43,7 +44,8 @@ class StartExamActivity : BaseActivity() { StartExamScreen( Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .addFocusCleaner() + .background(color = QuackColor.White.value) .systemBarsPadding(), ) } diff --git a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamScreen.kt b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamScreen.kt index 6e21a1432..1e23b42c6 100644 --- a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamScreen.kt +++ b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamScreen.kt @@ -15,13 +15,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.feature.start.exam.screen.exam.StartExamInputScreen import team.duckie.app.android.feature.start.exam.screen.quiz.StartQuizInputScreen import team.duckie.app.android.feature.start.exam.viewmodel.StartExamState import team.duckie.app.android.feature.start.exam.viewmodel.StartExamViewModel -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackTitle1 +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.ui.sugar.QuackTitle1 @Composable internal fun StartExamScreen( @@ -57,7 +57,7 @@ private fun StartExamLoadingScreen(modifier: Modifier, viewModel: StartExamViewM ) { // TODO(riflockle7): 추후 DuckieCircularProgressIndicator.kt 와 합치거나 꽥꽥 컴포넌트로 필요 CircularProgressIndicator( - color = QuackColor.DuckieOrange.composeColor, + color = QuackColor.DuckieOrange.value, ) } } diff --git a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/exam/StartExamInputScreen.kt b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/exam/StartExamInputScreen.kt index c07118a10..07afd8d11 100644 --- a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/exam/StartExamInputScreen.kt +++ b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/exam/StartExamInputScreen.kt @@ -5,6 +5,8 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.start.exam.screen.exam import androidx.compose.foundation.background @@ -25,17 +27,16 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import team.duckie.app.android.common.compose.ui.BackPressedTopAppBar import team.duckie.app.android.common.compose.ui.ImeSpacer +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.feature.start.exam.R import team.duckie.app.android.feature.start.exam.screen.StartExamScreen import team.duckie.app.android.feature.start.exam.viewmodel.StartExamState import team.duckie.app.android.feature.start.exam.viewmodel.StartExamViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackGrayscaleTextField -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi /** * 시험 시작 입력 화면 @@ -53,7 +54,7 @@ internal fun StartExamInputScreen(modifier: Modifier, viewModel: StartExamViewMo state.certifyingStatementInputText } - Column(modifier = modifier) { + Column(modifier = modifier.fillMaxWidth()) { // 상단 탭바 BackPressedTopAppBar(onBackPressed = viewModel::finishStartExam) @@ -83,17 +84,17 @@ internal fun StartExamInputScreen(modifier: Modifier, viewModel: StartExamViewMo Spacer(modifier = Modifier.weight(1f)) // 시험시작 버튼 - QuackLargeButton( - modifier = Modifier.padding( - vertical = 12.dp, - horizontal = 16.dp, - ), - type = QuackLargeButtonType.Fill, + TempFlexiblePrimaryLargeButton( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 16.dp, + ), text = stringResource(id = R.string.start_exam_start_button), - enabled = viewModel.startExamValidate(), onClick = viewModel::startSolveProblem, + enabled = viewModel.startExamValidate(), ) - ImeSpacer() } } @@ -117,14 +118,14 @@ internal fun StartExamTextField( BasicTextField( modifier = modifier .fillMaxWidth() - .background(color = QuackColor.Gray4.composeColor) + .background(color = QuackColor.Gray4.value) .padding( vertical = 17.dp, horizontal = 20.dp, ), value = text, onValueChange = onTextChanged, - textStyle = QuackTextStyle.Body1.asComposeStyle(), + textStyle = QuackTypography.Body1.asComposeStyle(), keyboardOptions = KeyboardOptions(imeAction = imeAction), keyboardActions = keyboardActions, singleLine = true, @@ -135,7 +136,7 @@ internal fun StartExamTextField( if (alwaysPlaceholderVisible || text.isEmpty()) { QuackText( text = placeholderText, - style = QuackTextStyle.Body1.change(color = QuackColor.Gray2), + typography = QuackTypography.Body1.change(color = QuackColor.Gray2), ) } } diff --git a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/quiz/StartQuizInputScreen.kt b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/quiz/StartQuizInputScreen.kt index 22be5763c..8d6864261 100644 --- a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/quiz/StartQuizInputScreen.kt +++ b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/quiz/StartQuizInputScreen.kt @@ -5,6 +5,8 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.start.exam.screen.quiz import androidx.compose.foundation.background @@ -14,32 +16,37 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import team.duckie.app.android.common.compose.ui.BackPressedTopAppBar import team.duckie.app.android.common.compose.ui.ImeSpacer import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.feature.start.exam.R +import team.duckie.app.android.feature.start.exam.screen.exam.StartExamTextField import team.duckie.app.android.feature.start.exam.viewmodel.StartExamState import team.duckie.app.android.feature.start.exam.viewmodel.StartExamViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackGrayscaleTextField -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.sugar.QuackBody2 +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 +import team.duckie.quackquack.ui.sugar.QuackTitle2 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun StartQuizInputScreen(modifier: Modifier, viewModel: StartExamViewModel) { @@ -49,8 +56,9 @@ internal fun StartQuizInputScreen(modifier: Modifier, viewModel: StartExamViewMo val certifyingStatementText: String = remember(state.certifyingStatementInputText) { state.certifyingStatementInputText } + val keyboard = LocalSoftwareKeyboardController.current - Column(modifier = modifier) { + Column(modifier = modifier.fillMaxWidth()) { BackPressedTopAppBar(onBackPressed = viewModel::finishStartExam) Column( modifier = Modifier @@ -62,7 +70,7 @@ internal fun StartQuizInputScreen(modifier: Modifier, viewModel: StartExamViewMo modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .background(color = QuackColor.Gray4.composeColor) + .background(color = QuackColor.Gray4.value) .padding(all = 12.dp), limitTime = state.timer, ) @@ -75,23 +83,31 @@ internal fun StartQuizInputScreen(modifier: Modifier, viewModel: StartExamViewMo modifier = Modifier.padding(top = 4.dp), text = state.requirementQuestion, // TODO(EvergreenTree97) 추후 도전 조건 response 생기면 변경 ) - QuackGrayscaleTextField( - modifier = Modifier.padding(top = 14.dp), + StartExamTextField( + modifier = Modifier + .padding(top = 14.dp) + .clip(RoundedCornerShape(8.dp)), text = certifyingStatementText, - onTextChanged = viewModel::inputCertifyingStatement, + alwaysPlaceholderVisible = false, placeholderText = "ex) ${state.requirementPlaceholder}", + onTextChanged = viewModel::inputCertifyingStatement, + keyboardActions = KeyboardActions { + keyboard?.hide() + viewModel.startSolveProblem() + }, ) } Spacer(modifier = Modifier.weight(1f)) - QuackLargeButton( - modifier = Modifier.padding( - vertical = 12.dp, - horizontal = 16.dp, - ), - type = QuackLargeButtonType.Fill, + TempFlexiblePrimaryLargeButton( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 16.dp, + ), text = stringResource(id = R.string.start_exam_quiz_start_button), - enabled = certifyingStatementText.isNotEmpty(), onClick = viewModel::startSolveProblem, + enabled = certifyingStatementText.isNotEmpty(), ) ImeSpacer() } @@ -109,27 +125,49 @@ private fun InfoBox( QuackTitle2(text = stringResource(id = R.string.start_exam_information_before_quiz_title)) Spacer(space = 4.dp) QuackBody2(text = stringResource(id = R.string.start_exam_information_before_quiz_line1)) - QuackText( - annotatedText = buildAnnotatedString { + Text( + style = QuackTypography.Body2.asComposeStyle().copy( + lineBreak = LineBreak.Simple, + ), + text = buildAnnotatedString { append(stringResource(id = R.string.start_exam_information_before_quiz_line2_prefix)) withStyle( - SpanStyle( - color = QuackColor.Black.composeColor, + style = SpanStyle( + color = Color.Black, fontWeight = FontWeight.Bold, ), ) { append( stringResource( - id = R.string.start_exam_information_before_quiz_line2_infix, + id = R.string.start_exam_information_before_quiz_line2_highlight, limitTime.toString(), ), ) } append( - stringResource(id = R.string.start_exam_information_before_quiz_line2_postfix), + stringResource( + id = R.string.start_exam_information_before_quiz_line2_infix, + limitTime.toString(), + ), ) + append(stringResource(id = R.string.start_exam_information_before_quiz_line2_postfix)) + }, + ) + Text( + style = QuackTypography.Body2.asComposeStyle().copy( + lineBreak = LineBreak.Simple, + ), + text = buildAnnotatedString { + append(stringResource(id = R.string.start_exam_information_before_quiz_line3_prefix)) + withStyle( + style = SpanStyle( + color = Color.Black, + fontWeight = FontWeight.Bold, + ), + ) { + append(stringResource(id = R.string.start_exam_information_before_quiz_line3_highlight)) + } }, - style = QuackTextStyle.Body2, ) } } diff --git a/feature/start-exam/src/main/res/values/strings.xml b/feature/start-exam/src/main/res/values/strings.xml index 1cdf0adc0..7bdc06afc 100644 --- a/feature/start-exam/src/main/res/values/strings.xml +++ b/feature/start-exam/src/main/res/values/strings.xml @@ -6,8 +6,11 @@ [ 퀴즈 시작 전 안내사항 ] ※ 문제를 틀릴 경우 바로 도전이 종료됩니다. ※ 문제당  - 약 %s초의 제한시간이 있습니다.  + 이 있습니다.  + 약 %s초의 제한시간 문제를 잘 읽고 시간 내에 물음에 답해주세요. + ※ 띄어쓰기는 저희가 할게요. 대소문자도 구분할 필요 없어요!  + 정확한 답만 입력해주세요! <도전 조건> 덕퀴즈 도전 diff --git a/feature/tag-edit/build.gradle.kts b/feature/tag-edit/build.gradle.kts index 7bf1bc5ae..22eceb406 100644 --- a/feature/tag-edit/build.gradle.kts +++ b/feature/tag-edit/build.gradle.kts @@ -28,7 +28,8 @@ dependencies { projects.common.compose, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, + libs.kotlin.collections.immutable, + libs.quack.v2.ui, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for CircularProgressIndicator libs.firebase.crashlytics, diff --git a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/TagEditActivity.kt b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/TagEditActivity.kt index d6bee34e4..7c0279520 100644 --- a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/TagEditActivity.kt +++ b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/TagEditActivity.kt @@ -16,13 +16,13 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.ui.Modifier import dagger.hilt.android.AndroidEntryPoint import org.orbitmvi.orbit.viewmodel.observe +import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded +import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.feature.tag.edit.screen.TagEditScreen import team.duckie.app.android.feature.tag.edit.viewmodel.TagEditSideEffect import team.duckie.app.android.feature.tag.edit.viewmodel.TagEditViewModel -import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded -import team.duckie.app.android.common.android.ui.BaseActivity -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.theme.QuackTheme @AndroidEntryPoint class TagEditActivity : BaseActivity() { @@ -36,7 +36,7 @@ class TagEditActivity : BaseActivity() { TagEditScreen( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .systemBarsPadding(), ) } diff --git a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/screen/TagEditScreen.kt b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/screen/TagEditScreen.kt index 92dd3cd7c..f365d2dd0 100644 --- a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/screen/TagEditScreen.kt +++ b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/screen/TagEditScreen.kt @@ -10,6 +10,7 @@ package team.duckie.app.android.feature.tag.edit.screen import android.app.Activity +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -26,7 +27,9 @@ import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.HideKeyboardWhenBottomSheetHidden import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.BackPressedHeadLine2TopAppBar import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.FavoriteTagSection import team.duckie.app.android.common.compose.ui.LoadingScreen @@ -35,11 +38,12 @@ import team.duckie.app.android.domain.tag.model.Tag import team.duckie.app.android.feature.tag.edit.R import team.duckie.app.android.feature.tag.edit.viewmodel.TagEditState import team.duckie.app.android.feature.tag.edit.viewmodel.TagEditViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText @Composable internal fun TagEditScreen( @@ -59,7 +63,6 @@ internal fun TagEditScreen( onTrailingClick = vm::onTrailingClick, addNewTags = vm::addNewTags, requestAddTag = vm::requestNewTag, - onTagClick = vm::onTrailingClick, ) is TagEditState.Error -> ErrorScreen( @@ -77,7 +80,6 @@ fun TagEditSuccessScreen( onTrailingClick: (Int) -> Unit, addNewTags: (List) -> Unit, requestAddTag: suspend (String) -> Tag?, - onTagClick: (Int) -> Unit, ) { val activity = LocalContext.current as Activity val coroutineScope = rememberCoroutineScope() @@ -86,6 +88,16 @@ fun TagEditSuccessScreen( skipHalfExpanded = true, ) + BackHandler { + if (sheetState.isVisible) { + coroutineScope.launch { sheetState.hide() } + } else { + activity.finish() + } + } + + HideKeyboardWhenBottomSheetHidden(sheetState) + DuckieTagAddBottomSheet( sheetState = sheetState, onDismissRequest = { newAddedTags, clearAction -> @@ -99,24 +111,17 @@ fun TagEditSuccessScreen( content = { Column(modifier = modifier) { // 상단 탭바 - QuackTopAppBar( - leadingIcon = QuackIcon.ArrowBack, - leadingText = stringResource(R.string.title), - onLeadingIconClick = activity::finish, + BackPressedHeadLine2TopAppBar( + title = stringResource(R.string.title), + onBackPressed = activity::finish, trailingContent = { - QuackSubtitle( + QuackText( modifier = Modifier - .then(Modifier) // prevent Modifier.Companion - .quackClickable( - rippleEnabled = false, - onClick = onEditFinishClick, - ) - .padding( - vertical = 4.dp, - horizontal = 16.dp, - ), + .quackClickable(onClick = onEditFinishClick), text = stringResource(R.string.edit_finish), - color = QuackColor.DuckieOrange, + typography = QuackTypography.Subtitle.change( + color = QuackColor.DuckieOrange, + ), singleLine = true, ) }, @@ -128,12 +133,11 @@ fun TagEditSuccessScreen( title = stringResource(id = R.string.my_favorite_tag), horizontalPadding = PaddingValues(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - trailingIcon = QuackIcon.Close, + trailingIcon = OutlinedGroup.Close, onTrailingClick = onTrailingClick, tags = state.myTags.map { it.name }.toPersistentList(), emptySection = {}, - singleLine = false, - onTagClick = onTagClick, + onTagClick = {}, addButtonTitle = stringResource(id = R.string.tag_edit_add_favorite_tag), onAddTagClick = { coroutineScope.launch { sheetState.show() } diff --git a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/viewmodel/TagEditViewModel.kt b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/viewmodel/TagEditViewModel.kt index 7d3441ff1..6a214a7e2 100644 --- a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/viewmodel/TagEditViewModel.kt +++ b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/viewmodel/TagEditViewModel.kt @@ -12,12 +12,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import team.duckie.app.android.common.kotlin.copy import team.duckie.app.android.domain.tag.model.Tag import team.duckie.app.android.domain.tag.usecase.TagCreateUseCase import team.duckie.app.android.domain.user.model.User @@ -56,13 +56,14 @@ internal class TagEditViewModel @Inject constructor( /** 각 태그 항목의 x 버튼 클릭 시 동작 */ fun onTrailingClick(index: Int) = intent { - val newTags = myTags.toMutableList().apply { removeAt(index) }.toPersistentList() + require(state is TagEditState.Success) + val newTags = (state as TagEditState.Success).myTags.copy { removeAt(index) } reduce { TagEditState.Success(myTags = newTags) } } /** 바텀 시트에서 오른쪽 방향 화살표를 눌러 태그 추가를 완료한다. */ fun addNewTags(newTag: List) { - val newTags = myTags.toMutableList().apply { addAll(newTag) }.toPersistentList() + val newTags = myTags.copy { addAll(newTag) } intent { reduce { TagEditState.Success(myTags = newTags) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38242ff3d..88761d828 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ androidx-annotation = "1.6.0" # compose compose-core = "1.4.2" -compose-foundation = "1.4.0-beta01" # use alpha for HorizontalPager +compose-foundation = "1.6.0-alpha04" # use alpha for HorizontalPager compose-material = "1.4.2" compose-runtime = "1.5.0-alpha03" # use alpha for SnapshotStateList#toList compose-lifecycle = "2.6.1" # use alpha for StateFlow#collectAsStateWithLifecycle @@ -114,7 +114,8 @@ quack-lint-compose = "1.0.2" # TODO(sungbin): quack-lint-writing # quack v2 -quack-v2-ui = "2.0.0-alpha07" +quack-v2-ui = "2.0.0-alpha11" +quack-v2-ui-plugin-interceptor-textfield ="2.0.0-alpha01" # test test-strikt = "0.34.1" @@ -258,6 +259,7 @@ quack-lint-quack = { module = "team.duckie.quack:quack-lint-quack", version.ref quack-lint-compose = { module = "team.duckie.quack:quack-lint-compose", version.ref = "quack-lint-compose" } quack-v2-ui = { module = "team.duckie.quackquack.ui:ui", version.ref = "quack-v2-ui" } +quack-v2-ui-plugin-interceptor-textfield = { module = "team.duckie.quackquack.ui:ui-plugin-interceptor-textfield", version.ref = "quack-v2-ui-plugin-interceptor-textfield"} test-strikt = { module = "io.strikt:strikt-core", version.ref = "test-strikt" } test-junit-core = { module = "junit:junit", version.ref = "test-junit-core" } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 02e4989f8..257daa54d 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -33,7 +33,7 @@ dependencies { libs.firebase.dynamic.links, libs.orbit.viewmodel, libs.androidx.splash, - libs.quack.ui.components, + libs.quack.v2.ui, libs.orbit.compose, ) } diff --git a/presentation/src/main/kotlin/team/duckie/app/android/presentation/IntroActivity.kt b/presentation/src/main/kotlin/team/duckie/app/android/presentation/IntroActivity.kt index 9dd9f6d01..47755fd73 100644 --- a/presentation/src/main/kotlin/team/duckie/app/android/presentation/IntroActivity.kt +++ b/presentation/src/main/kotlin/team/duckie/app/android/presentation/IntroActivity.kt @@ -32,20 +32,20 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.orbitmvi.orbit.viewmodel.observe import team.duckie.app.android.common.android.deeplink.DynamicLinkHelper -import team.duckie.app.android.domain.user.model.UserStatus +import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded +import team.duckie.app.android.common.android.ui.BaseActivity +import team.duckie.app.android.common.android.ui.changeActivityWithAnimation +import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.common.kotlin.seconds import team.duckie.app.android.core.datastore.PreferenceKey import team.duckie.app.android.core.datastore.dataStore +import team.duckie.app.android.domain.user.model.UserStatus import team.duckie.app.android.feature.home.screen.MainActivity import team.duckie.app.android.feature.onboard.OnboardActivity import team.duckie.app.android.presentation.screen.IntroScreen import team.duckie.app.android.presentation.viewmodel.IntroViewModel import team.duckie.app.android.presentation.viewmodel.sideeffect.IntroSideEffect -import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded -import team.duckie.app.android.common.kotlin.seconds -import team.duckie.app.android.common.android.ui.BaseActivity -import team.duckie.app.android.common.android.ui.changeActivityWithAnimation -import team.duckie.app.android.common.android.ui.const.Extras -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.theme.QuackTheme private val SplashScreenExitAnimationDurationMillis = 0.2.seconds private val SplashScreenFinishDurationMillis = 1.5.seconds diff --git a/presentation/src/main/kotlin/team/duckie/app/android/presentation/screen/IntroScreen.kt b/presentation/src/main/kotlin/team/duckie/app/android/presentation/screen/IntroScreen.kt index 76da2906c..3f1f8118b 100644 --- a/presentation/src/main/kotlin/team/duckie/app/android/presentation/screen/IntroScreen.kt +++ b/presentation/src/main/kotlin/team/duckie/app/android/presentation/screen/IntroScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -23,16 +24,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import team.duckie.app.android.presentation.R -import team.duckie.app.android.presentation.viewmodel.IntroViewModel +import org.orbitmvi.orbit.compose.collectAsState import team.duckie.app.android.common.android.intent.goToMarket import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.systemBarPaddings -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackImage -import org.orbitmvi.orbit.compose.collectAsState import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog +import team.duckie.app.android.presentation.R +import team.duckie.app.android.presentation.viewmodel.IntroViewModel +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 @Composable internal fun IntroScreen( @@ -43,7 +44,7 @@ internal fun IntroScreen( Column( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .padding(systemBarPaddings) .padding( top = 78.dp, @@ -60,21 +61,26 @@ internal fun IntroScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { QuackImage( - src = team.duckie.quackquack.ui.R.drawable.quack_duckie_text_logo, - size = DpSize( - width = 110.dp, - height = 32.dp, + modifier = Modifier.size( + size = DpSize( + width = 110.dp, + height = 32.dp, + ), ), + src = R.drawable.duckie_text_logo, ) QuackHeadLine1(text = stringResource(R.string.intro_slogan)) } QuackImage( - modifier = Modifier.offset(x = 125.dp), + modifier = Modifier + .size( + size = DpSize( + width = 276.dp, + height = 255.dp, + ), + ) + .offset(x = 125.dp), src = R.drawable.img_duckie_intro, - size = DpSize( - width = 276.dp, - height = 255.dp, - ), ) } diff --git a/presentation/src/main/res/drawable/duckie_text_logo.xml b/presentation/src/main/res/drawable/duckie_text_logo.xml new file mode 100644 index 000000000..3b570b212 --- /dev/null +++ b/presentation/src/main/res/drawable/duckie_text_logo.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 48c59ab18..d474bc720 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,7 +51,7 @@ include( ":feature:home", ":feature:start-exam", ":feature:solve-problem", - ":feature:create-problem", + ":feature:create-exam", ":feature:detail", ":feature:exam-result", ":feature:profile",