diff --git a/AGENTS.md b/AGENTS.md index 7e4cba085..492258161 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,3 +138,10 @@ Follow existing patterns first. Repo has minimal automated formatting/lint beyon - Team conventions (external): - https://github.com/EAT-SSU/Android/wiki/Android-convention - https://github.com/EAT-SSU/Android/wiki/Git-convention + +## Commit Message Convention +Format: `: ` +Examples: `fix: 00 고침`, `feat: 이력서 섹션 추가`, `style: 타이포 조정` +Keep the type in English (Conventional Commits style), and write the description in Korean. +간결하지만 자세한 작업 내용을 한글로 커밋한다. +커밋은 한 커밋에 하나의 일만 포함한다. 여러가지 일은 커밋을 쪼갠다. diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt index b25b1f3ae..052432b73 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt @@ -223,13 +223,15 @@ internal fun ReviewListScreen( rating = 0.0, ) ) - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + ) { Spacer( modifier = Modifier .padding(vertical = 16.dp) - .fillMaxWidth() // 가로 전체 차지 + .fillMaxWidth() .height(16.dp) - .background(Gray100) // 배경색 적용 + .background(Gray100) ) Row(Modifier.padding(horizontal = 24.dp)) { @@ -246,9 +248,9 @@ internal fun ReviewListScreen( } Box( modifier = Modifier - .align(Alignment.CenterHorizontally) - .fillMaxHeight() - .padding(top = 100.dp) + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center ) { DelayedLoadingIndicator(modifier = Modifier) } @@ -263,14 +265,12 @@ internal fun ReviewListScreen( val isInitialLoading = loadState.refresh is LoadState.Loading val isError = loadState.refresh is LoadState.Error - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - item { + if (isInitialLoading || isError || reviewPagingItems.itemCount == 0) { + Column( + modifier = Modifier.fillMaxSize(), + ) { ReviewInfoContent(menuName, info) - } - item { Spacer( modifier = Modifier .padding(vertical = 16.dp) @@ -278,9 +278,7 @@ internal fun ReviewListScreen( .height(16.dp) .background(Gray100) ) - } - item { Row(Modifier.padding(horizontal = 24.dp)) { Text( stringResource(R.string.review), @@ -293,27 +291,16 @@ internal fun ReviewListScreen( style = EatssuTheme.typography.h2, ) } - } - if (isInitialLoading) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 100.dp), - contentAlignment = Alignment.Center - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + if (isInitialLoading) { DelayedLoadingIndicator(modifier = Modifier) - } - } - } else if (isError) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 100.dp), - contentAlignment = Alignment.Center - ) { + } else if (isError) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( stringResource(R.string.toast_review_load_failed), @@ -326,23 +313,46 @@ internal fun ReviewListScreen( modifier = Modifier.width(100.dp) ) } + } else if (reviewPagingItems.itemCount == 0) { + EmptyReviewContent( + modifier = Modifier.fillMaxWidth(), + ) } } - } else if (reviewPagingItems.itemCount == 0) { + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { + ReviewInfoContent(menuName, info) + } + item { - Box( + Spacer( modifier = Modifier - .align(Alignment.CenterHorizontally) - .fillMaxHeight(), - contentAlignment = Alignment.Center - ) { - EmptyReviewContent( - modifier = Modifier - .fillMaxWidth(), + .padding(vertical = 16.dp) + .fillMaxWidth() + .height(16.dp) + .background(Gray100) + ) + } + + item { + Row(Modifier.padding(horizontal = 24.dp)) { + Text( + stringResource(R.string.review), + style = EatssuTheme.typography.h2, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "${info?.reviewCnt}", + color = Primary, + style = EatssuTheme.typography.h2, ) } } - } else { + items( count = reviewPagingItems.itemCount, key = reviewPagingItems.itemKey { it.reviewId } diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewScreen.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewScreen.kt index 9aead8ffc..d44654606 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewScreen.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewScreen.kt @@ -1,5 +1,6 @@ package com.eatssu.android.presentation.cafeteria.review.modify +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -18,6 +19,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -36,6 +40,7 @@ import com.eatssu.common.UiState import com.eatssu.common.enums.ScreenId import com.eatssu.design_system.component.CloseTopBar import com.eatssu.design_system.component.EatSsuButton +import com.eatssu.design_system.component.EatSsuDialog import com.eatssu.design_system.component.RatingBarMedium import com.eatssu.design_system.theme.EatssuTheme import com.eatssu.design_system.theme.Gray100 @@ -78,6 +83,40 @@ fun ModifyReviewScreen( } } + var showExitDialog by remember { mutableStateOf(false) } + + val hasUnsavedChanges = (ui as? UiState.Success)?.data?.let { + (it as? ModifyState.Editing)?.hasChanges ?: false + } ?: false + + val handleBack = { + if (hasUnsavedChanges) { + showExitDialog = true + } else { + onBack() + } + } + + BackHandler(enabled = true) { + handleBack() + } + + if (showExitDialog) { + EatSsuDialog( + title = stringResource(R.string.review_exit_dialog_title), + description = stringResource(R.string.review_exit_dialog_description), + confirmText = stringResource(R.string.review_exit_dialog_confirm), + dismissText = stringResource(R.string.review_exit_dialog_dismiss), + onConfirmClick = { showExitDialog = false }, + onDismissButtonClick = { + showExitDialog = false + onBack() + }, + onDismissRequest = { showExitDialog = false }, + visible = showExitDialog + ) + } + when (val data = (ui as? UiState.Success)?.data) { is ModifyState.Editing -> { ModifyReviewScreen( @@ -88,7 +127,7 @@ fun ModifyReviewScreen( menuLikeInfos = data.menuLikeInfos, isSubmitting = false, canSubmit = data.canSubmit, - onBack = onBack, + onBack = handleBack, onRatingChanged = viewModel::onRatingChanged, onContentChanged = { new -> if (new.length <= MAX_TEXT_COUNT) viewModel.onContentChanged(new) @@ -108,7 +147,7 @@ fun ModifyReviewScreen( menuLikeInfos = data.menuLikeInfos, isSubmitting = true, canSubmit = false, - onBack = onBack, + onBack = handleBack, onRatingChanged = {}, // 수정 불가 onContentChanged = {}, // 수정 불가 onToggleLike = {}, // 수정 불가 diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt index 9aeb6e235..45a4c0e6d 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt @@ -1,6 +1,7 @@ package com.eatssu.android.presentation.cafeteria.review.write import android.net.Uri +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -30,6 +31,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -39,9 +43,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.AsyncImage import com.eatssu.android.R import com.eatssu.android.domain.model.MenuMini import com.eatssu.android.presentation.cafeteria.review.write.component.MenuLikeButtonItem @@ -53,6 +57,7 @@ import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.ScreenId import com.eatssu.design_system.component.CloseTopBar import com.eatssu.design_system.component.EatSsuButton +import com.eatssu.design_system.component.EatSsuDialog import com.eatssu.design_system.component.RatingBarMedium import com.eatssu.design_system.theme.EatssuTheme import com.eatssu.design_system.theme.Gray100 @@ -62,7 +67,7 @@ import com.eatssu.design_system.theme.Gray400 import com.eatssu.design_system.theme.Gray500 import com.eatssu.design_system.theme.Gray700 import com.eatssu.design_system.theme.Primary -import androidx.core.net.toUri +import coil.compose.AsyncImage const val MAX_TEXT_COUNT = 300 @@ -102,6 +107,41 @@ fun WriteReviewScreen( } } + var showExitDialog by remember { mutableStateOf(false) } + + val hasUnsavedChanges = when (val data = (ui as? UiState.Success)?.data) { + is WriteReviewState.Editing -> data.rating > 0 || data.content.isNotEmpty() || data.selectedImageUri != null + else -> false + } + + val handleBack = { + if (hasUnsavedChanges) { + showExitDialog = true + } else { + onBack() + } + } + + BackHandler(enabled = true) { + handleBack() + } + + if (showExitDialog) { + EatSsuDialog( + title = stringResource(R.string.review_exit_dialog_title), + description = stringResource(R.string.review_exit_dialog_description), + confirmText = stringResource(R.string.review_exit_dialog_confirm), + dismissText = stringResource(R.string.review_exit_dialog_dismiss), + onConfirmClick = { showExitDialog = false }, + onDismissButtonClick = { + showExitDialog = false + onBack() + }, + onDismissRequest = { showExitDialog = false }, + visible = showExitDialog + ) + } + when (val data = (ui as? UiState.Success)?.data) { is WriteReviewState.Editing -> { WriteReviewScreen( @@ -113,7 +153,7 @@ fun WriteReviewScreen( likedMenuIds = data.likedMenuIds, selectedImageUri = data.selectedImageUri, isPosting = false, - onBack = onBack, + onBack = handleBack, onRatingChanged = viewModel::onRatingChanged, onContentChanged = { new -> if (new.length <= MAX_TEXT_COUNT) viewModel.onContentChanged(new) @@ -135,7 +175,7 @@ fun WriteReviewScreen( likedMenuIds = data.likedMenuIds, selectedImageUri = data.selectedImageUri, isPosting = true, - onBack = onBack, + onBack = handleBack, onRatingChanged = {}, // 비활성 onContentChanged = {}, // 비활성 onToggleLike = {}, // 비활성 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bffc235f2..0c6e3c480 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -146,6 +146,10 @@ 리뷰 설정 에러가 발생했습니다. 리뷰 작성하기 + 나가시겠어요? + 지금 나가면 작성한 내용이 저장되지 않습니다. + 계속 작성 + 나가기 diff --git a/core/design-system/src/main/java/com/eatssu/design_system/component/EatSsuDialog.kt b/core/design-system/src/main/java/com/eatssu/design_system/component/EatSsuDialog.kt index ff7ed7234..308b017eb 100644 --- a/core/design-system/src/main/java/com/eatssu/design_system/component/EatSsuDialog.kt +++ b/core/design-system/src/main/java/com/eatssu/design_system/component/EatSsuDialog.kt @@ -25,84 +25,140 @@ import com.eatssu.design_system.theme.EatssuTheme import com.eatssu.design_system.theme.Gray200 import com.eatssu.design_system.theme.Gray600 import com.eatssu.design_system.theme.White +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import android.graphics.drawable.ColorDrawable +import android.view.ViewGroup +import android.view.WindowManager +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider -@OptIn(ExperimentalMaterial3Api::class) @Composable fun EatSsuDialog( + visible: Boolean, title: String, description: String, confirmText: String, onConfirmClick: () -> Unit, onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, dismissText: String? = null, onDismissButtonClick: (() -> Unit)? = null, ) { - BasicAlertDialog( + if (!visible) return + + Dialog( onDismissRequest = onDismissRequest, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) ) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(28.dp), - color = White + val view = LocalView.current + SideEffect { + (view.parent as? DialogWindowProvider)?.window?.let { window -> + window.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + window.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) + window.setDimAmount(0f) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center ) { - Column( - modifier = Modifier - .padding(horizontal = 18.dp, vertical = 18.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn( + initialScale = 0.9f, + animationSpec = spring(dampingRatio = 0.7f) + ), + exit = fadeOut() + scaleOut() ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally + Surface( + modifier = Modifier + .padding(24.dp), + shape = RoundedCornerShape(28.dp), + color = White ) { - Text( - text = title, - style = EatssuTheme.typography.h2, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = description, - style = EatssuTheme.typography.subtitle2, - color = Gray600 - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (!dismissText.isNullOrBlank()) { - Button( - modifier = Modifier.weight(1f), - onClick = { onDismissButtonClick?.invoke() ?: onDismissRequest() }, - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Gray200, - contentColor = Black - ) + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = dismissText, - style = EatssuTheme.typography.button2 + text = title, + style = EatssuTheme.typography.h2, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = description, + style = EatssuTheme.typography.subtitle2, + color = Gray600 ) } - } - Button( - modifier = Modifier.weight(1f), - onClick = onConfirmClick, - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = Color.White - ) - ) { - Text( - text = confirmText, - style = EatssuTheme.typography.button2 - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (!dismissText.isNullOrBlank()) { + Button( + modifier = Modifier.weight(1f), + onClick = { + onDismissButtonClick?.invoke() + ?: onDismissRequest() + }, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Gray200, + contentColor = Black + ) + ) { + Text( + text = dismissText, + style = EatssuTheme.typography.button2 + ) + } + } + + Button( + modifier = Modifier.weight(1f), + onClick = onConfirmClick, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = Color.White + ) + ) { + Text( + text = confirmText, + style = EatssuTheme.typography.button2 + ) + } + } } } } @@ -126,7 +182,6 @@ fun EatSsuWarningDialog( onDismissRequest = onDismissRequest, ) { Surface( - modifier = modifier, shape = RoundedCornerShape(28.dp), color = White ) { @@ -196,13 +251,16 @@ fun EatSsuWarningDialog( @Composable private fun EatSsuDialogPreview() { EatssuTheme { + var showDialog by remember { mutableStateOf(false) } + EatSsuDialog( - title = "리뷰를 삭제하시겠어요?", - description = "삭제한 리뷰는 다시 복구할 수 없습니다.", - confirmText = "확인", - dismissText = "취소", - onConfirmClick = {}, - onDismissRequest = {} + visible = showDialog, + title = "나가시겠어요?", + description = "지금 나가면 작성한 내용이 저장되지 않습니다.", + confirmText = "계속 작성", + dismissText = "나가기", + onConfirmClick = { showDialog = false }, + onDismissRequest = { showDialog = false } ) } } @@ -211,12 +269,15 @@ private fun EatSsuDialogPreview() { @Composable private fun EatSsuDialog1Preview() { EatssuTheme { + var showDialog by remember { mutableStateOf(false) } + EatSsuDialog( + visible = showDialog, title = "리뷰를 삭제하시겠어요?", description = "삭제한 리뷰는 다시 복구할 수 없습니다.", confirmText = "확인", - onConfirmClick = {}, - onDismissRequest = {} + onConfirmClick = { showDialog = false }, + onDismissRequest = { showDialog = false } ) } }