diff --git a/core/design-system/src/main/java/com/moneymong/moneymong/design_system/component/tag/Tag.kt b/core/design-system/src/main/java/com/moneymong/moneymong/design_system/component/tag/Tag.kt index 2f60719a..935b6e12 100644 --- a/core/design-system/src/main/java/com/moneymong/moneymong/design_system/component/tag/Tag.kt +++ b/core/design-system/src/main/java/com/moneymong/moneymong/design_system/component/tag/Tag.kt @@ -2,6 +2,7 @@ package com.moneymong.moneymong.design_system.component.tag import androidx.annotation.DrawableRes import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -18,7 +19,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.moneymong.moneymong.design_system.theme.Blue04 import com.moneymong.moneymong.design_system.theme.Body2 +import com.moneymong.moneymong.design_system.theme.Body3 +import com.moneymong.moneymong.design_system.theme.Gray03 +import com.moneymong.moneymong.design_system.theme.Gray05 +import com.moneymong.moneymong.design_system.theme.Gray06 import com.moneymong.moneymong.design_system.theme.White +import com.moneymong.moneymong.ui.noRippleClickable @Composable fun MDSTag( @@ -54,6 +60,46 @@ fun MDSTag( } } +@Composable +fun MDSOutlineTag( + modifier: Modifier = Modifier, + text: String, + @DrawableRes iconResource: Int? = null, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .border( + width = 1.4.dp, + color = Gray03, + shape = RoundedCornerShape(size = Int.MAX_VALUE.dp) + ) + .background( + color = White, + shape = RoundedCornerShape(size = Int.MAX_VALUE.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + color = Gray06, + style = Body3, + ) + if (iconResource != null) { + Icon( + modifier = Modifier + .size(18.dp) + .noRippleClickable(onClick), + painter = painterResource(id = iconResource), + contentDescription = "Tag icon", + tint = Gray05 + ) + } + } +} + @Preview(showBackground = true) @Composable fun MDSTagPreview() { @@ -73,4 +119,23 @@ fun MDSTagPreview() { iconResource = com.moneymong.moneymong.design_system.R.drawable.ic_pencil ) } -} \ No newline at end of file +} + +@Preview(showBackground = true) +@Composable +fun MDSOutlineTagPreview() { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MDSOutlineTag( + text = "tag", + onClick = {}, + ) + MDSOutlineTag( + text = "tag", + iconResource = com.moneymong.moneymong.design_system.R.drawable.ic_close_default, + onClick = {}, + ) + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 355cba4d..037f5fcc 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -37,6 +37,7 @@ android { dependencies { implementation(projects.core.common) implementation(projects.core.model) + implementation(projects.domain) implementation(libs.androidx.core.ktx) implementation(libs.appcompat) implementation(libs.material) diff --git a/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualScreen.kt b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualScreen.kt index 067b91a7..288df93f 100644 --- a/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualScreen.kt +++ b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.gestures.detectTapGestures 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 @@ -26,13 +28,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,6 +59,7 @@ import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.moneymong.moneymong.android.util.base64ToFile import com.moneymong.moneymong.android.util.encodingBase64 +import com.moneymong.moneymong.design_system.R import com.moneymong.moneymong.ui.noRippleClickable import com.moneymong.moneymong.design_system.R.drawable import com.moneymong.moneymong.design_system.component.button.MDSButton @@ -60,6 +67,7 @@ import com.moneymong.moneymong.design_system.component.button.MDSButtonSize import com.moneymong.moneymong.design_system.component.button.MDSButtonType import com.moneymong.moneymong.design_system.component.modal.MDSModal import com.moneymong.moneymong.design_system.component.selection.MDSSelection +import com.moneymong.moneymong.design_system.component.tag.MDSOutlineTag import com.moneymong.moneymong.design_system.component.textfield.MDSTextField import com.moneymong.moneymong.design_system.component.textfield.util.MDSTextFieldIcons import com.moneymong.moneymong.design_system.component.textfield.util.withRequiredMark @@ -68,18 +76,23 @@ import com.moneymong.moneymong.design_system.component.textfield.visualtransform import com.moneymong.moneymong.design_system.component.textfield.visualtransformation.TimeVisualTransformation import com.moneymong.moneymong.design_system.error.ErrorDialog import com.moneymong.moneymong.design_system.theme.Blue03 +import com.moneymong.moneymong.design_system.theme.Blue04 import com.moneymong.moneymong.design_system.theme.Body2 import com.moneymong.moneymong.design_system.theme.Body3 import com.moneymong.moneymong.design_system.theme.Gray06 import com.moneymong.moneymong.design_system.theme.Gray10 import com.moneymong.moneymong.design_system.theme.MMHorizontalSpacing import com.moneymong.moneymong.design_system.theme.White +import com.moneymong.moneymong.ledgermanual.view.LedgerManualCategoryBottomSheet import com.moneymong.moneymong.ledgermanual.view.LedgerManualTopbarView import com.moneymong.moneymong.model.ledger.FundType +import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect -@OptIn(ExperimentalGlideComposeApi::class) +@OptIn(ExperimentalGlideComposeApi::class, ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class +) @Composable fun LedgerManualScreen( modifier: Modifier = Modifier, @@ -99,6 +112,8 @@ fun LedgerManualScreen( } } ) + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() viewModel.collectSideEffect { when (it) { @@ -168,6 +183,21 @@ fun LedgerManualScreen( ) } + if (state.showBottomSheet) { + LedgerManualCategoryBottomSheet( + sheetState = sheetState, + categories = emptyList(), + categoryValue = state.categoryValue, + isSystemCategoryError = state.isSystemCategoryError, + onDismissRequest = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { viewModel.onDismissBottomSheet() } + }, + onChangeCategoryValue = viewModel::onChangeCategoryValue + ) + } + Scaffold( topBar = { LedgerManualTopbarView( @@ -311,6 +341,34 @@ fun LedgerManualScreen( keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) ) Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "카테고리", + style = Body2, + color = Gray06, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier.noRippleClickable(viewModel::onClickCategoryEdit), + text = "수정", + style = Body2, + color = Blue04, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + MDSOutlineTag( + text = "Test", // TODO + iconResource = drawable.ic_close_default, + onClick = {}, + ) + } + Spacer(modifier = Modifier.height(24.dp)) Text( text = "사진 첨부 (최대 12장)", style = Body2, diff --git a/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualState.kt b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualState.kt index 294d825a..1a3a2eea 100644 --- a/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualState.kt +++ b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualState.kt @@ -25,7 +25,9 @@ data class LedgerManualState( val isMemoError: Boolean = false, val showPopBackStackModal: Boolean = false, val showErrorDialog: Boolean = false, - val errorMessage: String = "" + val errorMessage: String = "", + val showBottomSheet: Boolean = false, + val categoryValue: TextFieldValue = TextFieldValue(), ) : State { val enabled: Boolean @@ -53,4 +55,11 @@ data class LedgerManualState( val formattedTime = timeFormat.format(timeFormat.parse(paymentTimeValue.text)) return "$formattedDate $formattedTime".toZonedDateTime("yyyyMMdd HHmmss") } + + val isSystemCategoryError: Boolean + get() = categoryValue.text == SYSTEM_CATEGORY + + companion object { + private const val SYSTEM_CATEGORY = "카테고리 없음" + } } diff --git a/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualViewModel.kt b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualViewModel.kt index ac10bc8a..89579c21 100644 --- a/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualViewModel.kt +++ b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/LedgerManualViewModel.kt @@ -177,6 +177,18 @@ class LedgerManualViewModel @Inject constructor( fun onClickErrorDialogConfirm() = eventEmit(LedgerManualSideEffect.LedgerManualHideErrorDialog) + fun onClickCategoryEdit() = intent { reduce { state.copy(showBottomSheet = true) } } + + fun onDismissBottomSheet() = intent { reduce { state.copy(showBottomSheet = false) } } + + fun onChangeCategoryValue(value: TextFieldValue) = blockingIntent { + val validate = value.text.validateValue(length = 10) + + if (validate) { + reduce { state.copy(categoryValue = value) } + } + } + private fun trimStartWithZero(value: TextFieldValue) = if (value.text.isNotEmpty() && value.text.all { it == '0' }) { value.copy(text = "0") diff --git a/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/view/LedgerManualBottomSheetType.kt b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/view/LedgerManualBottomSheetType.kt new file mode 100644 index 00000000..cc25f684 --- /dev/null +++ b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/view/LedgerManualBottomSheetType.kt @@ -0,0 +1,6 @@ +package com.moneymong.moneymong.ledgermanual.view + +enum class LedgerManualBottomSheetType { + LIST, + CREATE, +} diff --git a/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/view/LedgerManualCategoryBottomSheet.kt b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/view/LedgerManualCategoryBottomSheet.kt new file mode 100644 index 00000000..ffd36754 --- /dev/null +++ b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/view/LedgerManualCategoryBottomSheet.kt @@ -0,0 +1,298 @@ +package com.moneymong.moneymong.ledgermanual.view + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.focus.onFocusChanged +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.moneymong.moneymong.design_system.theme.MMHorizontalSpacing +import com.moneymong.moneymong.design_system.R +import com.moneymong.moneymong.design_system.component.bottomSheet.MDSBottomSheet +import com.moneymong.moneymong.design_system.component.button.MDSButton +import com.moneymong.moneymong.design_system.component.button.MDSButtonSize +import com.moneymong.moneymong.design_system.component.button.MDSButtonType +import com.moneymong.moneymong.design_system.component.tag.MDSOutlineTag +import com.moneymong.moneymong.design_system.component.textfield.MDSTextField +import com.moneymong.moneymong.design_system.component.textfield.util.MDSTextFieldIcons +import com.moneymong.moneymong.design_system.theme.Black +import com.moneymong.moneymong.design_system.theme.Blue04 +import com.moneymong.moneymong.design_system.theme.Body2 +import com.moneymong.moneymong.design_system.theme.Body3 +import com.moneymong.moneymong.design_system.theme.Gray05 +import com.moneymong.moneymong.design_system.theme.Gray07 +import com.moneymong.moneymong.design_system.theme.Heading1 +import com.moneymong.moneymong.design_system.theme.Heading4 +import com.moneymong.moneymong.design_system.theme.White +import com.moneymong.moneymong.ui.noRippleClickable + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LedgerManualCategoryBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + categories: List, // TODO API response + categoryValue: TextFieldValue, + isSystemCategoryError: Boolean, + onDismissRequest: () -> Unit, + onChangeCategoryValue: (TextFieldValue) -> Unit, +) { + var sheetType by remember { mutableStateOf(LedgerManualBottomSheetType.LIST) } + + MDSBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + AnimatedContent( + targetState = sheetType, + transitionSpec = { + when (targetState) { + LedgerManualBottomSheetType.CREATE -> { + slideInHorizontally { fullWidth -> fullWidth } + .togetherWith(slideOutHorizontally { fullWidth -> fullWidth / -3 }) + } + + LedgerManualBottomSheetType.LIST -> { + slideInHorizontally { fullWidth -> -fullWidth } + .togetherWith(slideOutHorizontally { fullWidth -> fullWidth / 3 }) + } + } + } + ) { targetState -> + when (targetState) { + LedgerManualBottomSheetType.LIST -> { + LedgerManualCategoryBottomSheetContent( + categories = categories, + onDismissRequest = onDismissRequest, + onClickCreate = { sheetType = LedgerManualBottomSheetType.CREATE } + ) + } + + LedgerManualBottomSheetType.CREATE -> { + LedgerManualCategoryCreateBottomSheetContent( + textFieldValue = categoryValue, + isSystemCategoryError = isSystemCategoryError, + categories = categories, + onValueChange = onChangeCategoryValue, + onClickRegister = {}, + onPrev = { sheetType = LedgerManualBottomSheetType.LIST } + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LedgerManualCategoryBottomSheetContent( + modifier: Modifier = Modifier, + categories: List, + onDismissRequest: () -> Unit, + onClickCreate: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .height(448.dp) + .background(White) + .padding(horizontal = MMHorizontalSpacing, vertical = 20.dp), + ) { + Icon( + modifier = Modifier + .align(alignment = Alignment.End) + .noRippleClickable(onDismissRequest), + painter = painterResource(R.drawable.ic_close_default), + contentDescription = null + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + style = Heading4, + color = Black, + text = "카테고리", + ) + Text( + modifier = Modifier.noRippleClickable(onClickCreate), + style = Body3, + color = Blue04, + text = "추가", + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + style = Body2, + color = Gray05, + text = "원하는 카테고리를 마음대로 만들 수 있어요", + ) + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + categories.forEach { + MDSOutlineTag( + text = it, + iconResource = R.drawable.ic_close_default, + onClick = {}, + ) + } + } + } +} + +@Composable +fun LedgerManualCategoryCreateBottomSheetContent( + modifier: Modifier = Modifier, + textFieldValue: TextFieldValue, + isSystemCategoryError: Boolean, + categories: List, // TODO API response + onValueChange: (TextFieldValue) -> Unit, + onClickRegister: () -> Unit, + onPrev: () -> Unit, +) { + val maxCount = 10 + var isFilled by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + val isExists = categories.contains(textFieldValue.text) + val helperText by remember(isSystemCategoryError, isExists) { + derivedStateOf { + when { + isSystemCategoryError -> "사용할 수 없는 카테고리 이름이에요" + isExists -> "이미 있는 카테고리에요" + else -> "" + } + } + } + + BackHandler { + onPrev() + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = modifier + .fillMaxWidth() + .background(White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(White) + .padding(horizontal = MMHorizontalSpacing, vertical = 20.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .size(24.dp) + .noRippleClickable { + keyboard?.hide() + onPrev() + }, + painter = painterResource(R.drawable.ic_chevron_left), + contentDescription = null, + tint = Gray07, + ) + Text( + text = "카테고리 생성", + style = Heading1, + color = Black, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + MDSTextField( + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { isFilled = !it.isFocused } + .focusRequester(focusRequester), + placeholder = "카테고리를 입력해주세요", + value = textFieldValue, + onValueChange = onValueChange, + title = "", + isFilled = isFilled, + isError = isSystemCategoryError || isExists, + singleLine = true, + helperText = helperText, + icon = MDSTextFieldIcons.Clear, + onIconClick = { onValueChange(TextFieldValue("")) }, + maxCount = maxCount + ) + } + val enabled = textFieldValue.text.isNotBlank() && (!isSystemCategoryError && !isExists) + MDSButton( + modifier = Modifier.fillMaxWidth(), + text = "등록", + type = MDSButtonType.PRIMARY, + size = MDSButtonSize.LARGE, + cornerShape = 0.dp, + enabled = enabled, + onClick = onClickRegister, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun LedgerManualCategoryBottomSheetContentPreview() { + val categories = listOf("testTooLongTextOverFlow", "test") + + LedgerManualCategoryBottomSheetContent( + categories = categories, + onDismissRequest = {}, + ) {} +} + +@Preview(showBackground = true) +@Composable +fun LedgerManualCategoryCreateBottomSheetContentPreview() { + LedgerManualCategoryCreateBottomSheetContent( + textFieldValue = TextFieldValue(), + isSystemCategoryError = false, + categories = emptyList(), + onValueChange = {}, + onClickRegister = {} + ) {} +} \ No newline at end of file