diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b690974d..329b3975 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "com.moneymong.moneymong" minSdk = 24 targetSdk = 35 - versionCode = 32 - versionName = "2.0.2" + versionCode = 33 + versionName = "2.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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..5929fafb 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,8 @@ 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.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -12,13 +14,21 @@ import androidx.compose.material3.Text 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.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.moneymong.moneymong.design_system.R 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.Gray08 import com.moneymong.moneymong.design_system.theme.White +import com.moneymong.moneymong.ui.noRippleClickable @Composable fun MDSTag( @@ -54,6 +64,64 @@ fun MDSTag( } } +@Composable +fun MDSOutlineTag( + modifier: Modifier = Modifier, + text: String, + selected: Boolean = false, + @DrawableRes iconResource: Int? = null, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .border( + width = 1.4.dp, + color = if (selected) Blue04 else Gray03, + shape = RoundedCornerShape(size = Int.MAX_VALUE.dp) + ) + .clip(RoundedCornerShape(size = Int.MAX_VALUE.dp)) + .background( + color = White, + shape = RoundedCornerShape(size = Int.MAX_VALUE.dp) + ) + .clickable( + enabled = iconResource == null, + onClick = onClick + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + color = if (selected) Gray08 else Gray06, + style = Body3, + ) + when { + !selected && iconResource != null -> { + Icon( + modifier = Modifier + .size(18.dp) + .noRippleClickable(onClick), + painter = painterResource(id = iconResource), + contentDescription = "Tag icon", + tint = Gray05 + ) + } + + selected -> { + Icon( + modifier = Modifier + .size(18.dp), + painter = painterResource(id = R.drawable.ic_check), + contentDescription = "Tag icon", + tint = Blue04 + ) + } + } + } +} + @Preview(showBackground = true) @Composable fun MDSTagPreview() { @@ -70,7 +138,28 @@ fun MDSTagPreview() { text = "tag", backgroundColor = Blue04, contentColor = White, - iconResource = com.moneymong.moneymong.design_system.R.drawable.ic_pencil + iconResource = R.drawable.ic_pencil + ) + } +} + +@Preview(showBackground = true) +@Composable +fun MDSOutlineTagPreview() { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MDSOutlineTag( + text = "tag", + selected = false, + onClick = {}, + ) + MDSOutlineTag( + text = "tag", + selected = true, + iconResource = R.drawable.ic_close_default, + onClick = {}, ) } -} \ No newline at end of file +} diff --git a/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryCreateRequest.kt b/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryCreateRequest.kt new file mode 100644 index 00000000..c7c1d059 --- /dev/null +++ b/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryCreateRequest.kt @@ -0,0 +1,6 @@ +package com.moneymong.moneymong.model.agency + +data class CategoryCreateRequest( + val agencyId: Long, + val name: String, +) diff --git a/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryCreateResponse.kt b/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryCreateResponse.kt new file mode 100644 index 00000000..cbf5d8bc --- /dev/null +++ b/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryCreateResponse.kt @@ -0,0 +1,6 @@ +package com.moneymong.moneymong.model.agency + +data class CategoryCreateResponse( + val agencyId: Long, + val name: String, +) diff --git a/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryDeleteRequest.kt b/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryDeleteRequest.kt new file mode 100644 index 00000000..85bf4897 --- /dev/null +++ b/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryDeleteRequest.kt @@ -0,0 +1,5 @@ +package com.moneymong.moneymong.model.agency + +data class CategoryDeleteRequest( + val categoryId: Long, +) diff --git a/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryReadResponse.kt b/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryReadResponse.kt new file mode 100644 index 00000000..50a4a317 --- /dev/null +++ b/core/model/src/main/java/com/moneymong/moneymong/model/agency/CategoryReadResponse.kt @@ -0,0 +1,11 @@ +package com.moneymong.moneymong.model.agency + +data class CategoryReadResponse( + val agencyId: Long, + val categories: List, +) + +data class CategoryResponse( + val id: Long, + val name: String, +) diff --git a/core/model/src/main/java/com/moneymong/moneymong/model/ledger/LedgerTransactionRequest.kt b/core/model/src/main/java/com/moneymong/moneymong/model/ledger/LedgerTransactionRequest.kt index 7436c0a6..0241b086 100644 --- a/core/model/src/main/java/com/moneymong/moneymong/model/ledger/LedgerTransactionRequest.kt +++ b/core/model/src/main/java/com/moneymong/moneymong/model/ledger/LedgerTransactionRequest.kt @@ -6,5 +6,6 @@ data class LedgerTransactionRequest( val amount: Int, val description: String, val paymentDate: String, - val documentImageUrls: List = emptyList() + val documentImageUrls: List = emptyList(), + val category: String? = null, ) diff --git a/core/model/src/main/java/com/moneymong/moneymong/model/ledgerdetail/LedgerTransactionDetailRequest.kt b/core/model/src/main/java/com/moneymong/moneymong/model/ledgerdetail/LedgerTransactionDetailRequest.kt index 2e729457..ee27c20e 100644 --- a/core/model/src/main/java/com/moneymong/moneymong/model/ledgerdetail/LedgerTransactionDetailRequest.kt +++ b/core/model/src/main/java/com/moneymong/moneymong/model/ledgerdetail/LedgerTransactionDetailRequest.kt @@ -4,5 +4,6 @@ data class LedgerTransactionDetailRequest( val storeInfo: String, val amount: Int, val description: String, - val paymentDate: String + val paymentDate: String, + val category: String? = null, ) \ No newline at end of file diff --git a/core/model/src/main/java/com/moneymong/moneymong/model/ledgerdetail/LedgerTransactionDetailResponse.kt b/core/model/src/main/java/com/moneymong/moneymong/model/ledgerdetail/LedgerTransactionDetailResponse.kt index 7c5ce8fa..fe9bae05 100644 --- a/core/model/src/main/java/com/moneymong/moneymong/model/ledgerdetail/LedgerTransactionDetailResponse.kt +++ b/core/model/src/main/java/com/moneymong/moneymong/model/ledgerdetail/LedgerTransactionDetailResponse.kt @@ -12,5 +12,6 @@ data class LedgerTransactionDetailResponse( val paymentDate: String, val receiptImageUrls: List, val documentImageUrls: List, - val authorName: String + val authorName: String, + val category: String?, ) \ No newline at end of file diff --git a/core/network/src/main/java/com/moneymong/moneymong/network/api/AgencyApi.kt b/core/network/src/main/java/com/moneymong/moneymong/network/api/AgencyApi.kt index bda1058b..4c4dd5a9 100644 --- a/core/network/src/main/java/com/moneymong/moneymong/network/api/AgencyApi.kt +++ b/core/network/src/main/java/com/moneymong/moneymong/network/api/AgencyApi.kt @@ -5,12 +5,17 @@ import com.moneymong.moneymong.model.agency.AgencyGetResponse import com.moneymong.moneymong.model.agency.AgencyJoinRequest import com.moneymong.moneymong.model.agency.AgencyJoinResponse import com.moneymong.moneymong.model.agency.AgencyRegisterRequest +import com.moneymong.moneymong.model.agency.CategoryCreateRequest +import com.moneymong.moneymong.model.agency.CategoryCreateResponse +import com.moneymong.moneymong.model.agency.CategoryDeleteRequest +import com.moneymong.moneymong.model.agency.CategoryReadResponse import com.moneymong.moneymong.model.agency.MyAgencyResponse import com.moneymong.moneymong.model.agency.RegisterAgencyResponse import com.moneymong.moneymong.model.member.InvitationCodeResponse import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.HTTP import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path @@ -38,6 +43,11 @@ interface AgencyApi { @Query("keyword") name: String ): Result> + @GET("api/v1/agencies/categories") + suspend fun fetchCategories( + @Query("agencyId") agencyId: Long + ): Result + // POST @POST("/api/v2/agencies/invitation-code") suspend fun agencyCodeNumbers( @@ -49,6 +59,11 @@ interface AgencyApi { @Body request: AgencyRegisterRequest ): Result + @POST("api/v1/agencies/categories") + suspend fun createCategory( + @Body request: CategoryCreateRequest + ): Result + // PATCH @PATCH("api/v1/agencies/{agencyId}/invitation-code") suspend fun reInvitationCode( @@ -60,4 +75,9 @@ interface AgencyApi { suspend fun deleteAgency( @Path("agencyId") agencyId: Int ): Result + + @HTTP(method = "DELETE", path = "api/v1/agencies/categories", hasBody = true) + suspend fun deleteCategory( + @Body request: CategoryDeleteRequest + ): Result } \ No newline at end of file diff --git a/core/network/src/main/java/com/moneymong/moneymong/network/api/LedgerDetailApi.kt b/core/network/src/main/java/com/moneymong/moneymong/network/api/LedgerDetailApi.kt index 5b785738..3a70ac6c 100644 --- a/core/network/src/main/java/com/moneymong/moneymong/network/api/LedgerDetailApi.kt +++ b/core/network/src/main/java/com/moneymong/moneymong/network/api/LedgerDetailApi.kt @@ -33,7 +33,7 @@ interface LedgerDetailApi { ): Result // PUT - @PUT("api/v1/ledger/ledger-detail/{detailId}") + @PUT("api/v2/ledger/ledger-detail/{detailId}") suspend fun updateLedgerTransactionDetail( @Path("detailId") detailId: Int, @Body body: LedgerTransactionDetailRequest diff --git a/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSource.kt b/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSource.kt index 3bd7fe51..a7e602fb 100644 --- a/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSource.kt +++ b/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSource.kt @@ -5,6 +5,10 @@ import com.moneymong.moneymong.model.agency.AgencyGetResponse import com.moneymong.moneymong.model.agency.AgencyJoinRequest import com.moneymong.moneymong.model.agency.AgencyJoinResponse import com.moneymong.moneymong.model.agency.AgencyRegisterRequest +import com.moneymong.moneymong.model.agency.CategoryCreateRequest +import com.moneymong.moneymong.model.agency.CategoryCreateResponse +import com.moneymong.moneymong.model.agency.CategoryDeleteRequest +import com.moneymong.moneymong.model.agency.CategoryReadResponse import com.moneymong.moneymong.model.agency.MyAgencyResponse import com.moneymong.moneymong.model.agency.RegisterAgencyResponse @@ -14,4 +18,7 @@ interface AgencyRemoteDataSource { suspend fun fetchMyAgencyList(): Result> suspend fun fetchAgencyByName(agencyName: String): Result> suspend fun agencyCodeNumbers(data: AgencyJoinRequest): Result + suspend fun createCategory(request: CategoryCreateRequest): Result + suspend fun fetchCategories(agencyId: Long): Result + suspend fun deleteCategory(request: CategoryDeleteRequest): Result } \ No newline at end of file diff --git a/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSourceImpl.kt b/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSourceImpl.kt index 2495a6d4..77e88ced 100644 --- a/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSourceImpl.kt +++ b/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSourceImpl.kt @@ -5,6 +5,10 @@ import com.moneymong.moneymong.model.agency.AgencyGetResponse import com.moneymong.moneymong.model.agency.AgencyJoinRequest import com.moneymong.moneymong.model.agency.AgencyJoinResponse import com.moneymong.moneymong.model.agency.AgencyRegisterRequest +import com.moneymong.moneymong.model.agency.CategoryCreateRequest +import com.moneymong.moneymong.model.agency.CategoryCreateResponse +import com.moneymong.moneymong.model.agency.CategoryDeleteRequest +import com.moneymong.moneymong.model.agency.CategoryReadResponse import com.moneymong.moneymong.model.agency.MyAgencyResponse import com.moneymong.moneymong.model.agency.RegisterAgencyResponse import com.moneymong.moneymong.network.api.AgencyApi @@ -35,4 +39,16 @@ class AgencyRemoteDataSourceImpl @Inject constructor( ): Result { return agencyApi.agencyCodeNumbers(body = data) } + + override suspend fun createCategory(request: CategoryCreateRequest): Result { + return agencyApi.createCategory(request = request) + } + + override suspend fun fetchCategories(agencyId: Long): Result { + return agencyApi.fetchCategories(agencyId = agencyId) + } + + override suspend fun deleteCategory(request: CategoryDeleteRequest): Result { + return agencyApi.deleteCategory(request = request) + } } \ No newline at end of file diff --git a/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSourceMock.kt b/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSourceMock.kt index 2aceeedd..bd1a6963 100644 --- a/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSourceMock.kt +++ b/data/src/main/java/com/moneymong/moneymong/data/datasource/agency/AgencyRemoteDataSourceMock.kt @@ -5,6 +5,10 @@ import com.moneymong.moneymong.model.agency.AgencyGetResponse import com.moneymong.moneymong.model.agency.AgencyJoinRequest import com.moneymong.moneymong.model.agency.AgencyJoinResponse import com.moneymong.moneymong.model.agency.AgencyRegisterRequest +import com.moneymong.moneymong.model.agency.CategoryCreateRequest +import com.moneymong.moneymong.model.agency.CategoryCreateResponse +import com.moneymong.moneymong.model.agency.CategoryDeleteRequest +import com.moneymong.moneymong.model.agency.CategoryReadResponse import com.moneymong.moneymong.model.agency.MyAgencyResponse import com.moneymong.moneymong.model.agency.RegisterAgencyResponse import kotlinx.coroutines.delay @@ -36,6 +40,18 @@ class AgencyRemoteDataSourceMock : AgencyRemoteDataSource { ) } + override suspend fun createCategory(request: CategoryCreateRequest): Result { + return Result.success(CategoryCreateResponse(agencyId = 1L, name = "category")) + } + + override suspend fun fetchCategories(agencyId: Long): Result { + return Result.success(CategoryReadResponse(agencyId = agencyId, categories = emptyList())) + } + + override suspend fun deleteCategory(request: CategoryDeleteRequest): Result { + return Result.success(Unit) + } + private companion object { val agenciesMockOfSuccess = listOf( Result.success( diff --git a/data/src/main/java/com/moneymong/moneymong/data/repository/agency/AgencyRepositoryImpl.kt b/data/src/main/java/com/moneymong/moneymong/data/repository/agency/AgencyRepositoryImpl.kt index 459a807c..bbbef68a 100644 --- a/data/src/main/java/com/moneymong/moneymong/data/repository/agency/AgencyRepositoryImpl.kt +++ b/data/src/main/java/com/moneymong/moneymong/data/repository/agency/AgencyRepositoryImpl.kt @@ -11,6 +11,10 @@ import com.moneymong.moneymong.model.agency.AgencyGetResponse import com.moneymong.moneymong.model.agency.AgencyJoinRequest import com.moneymong.moneymong.model.agency.AgencyJoinResponse import com.moneymong.moneymong.model.agency.AgencyRegisterRequest +import com.moneymong.moneymong.model.agency.CategoryCreateRequest +import com.moneymong.moneymong.model.agency.CategoryCreateResponse +import com.moneymong.moneymong.model.agency.CategoryDeleteRequest +import com.moneymong.moneymong.model.agency.CategoryReadResponse import com.moneymong.moneymong.model.agency.MyAgencyResponse import com.moneymong.moneymong.model.agency.RegisterAgencyResponse import kotlinx.coroutines.flow.Flow @@ -49,4 +53,13 @@ class AgencyRepositoryImpl @Inject constructor( override suspend fun fetchAgencyId(): Int = agencyLocalDataSource.fetchAgencyId() + + override suspend fun createCategory(request: CategoryCreateRequest): Result = + agencyRemoteDataSource.createCategory(request = request) + + override suspend fun fetchCategories(agencyId: Long): Result = + agencyRemoteDataSource.fetchCategories(agencyId = agencyId) + + override suspend fun deleteCategory(request: CategoryDeleteRequest): Result = + agencyRemoteDataSource.deleteCategory(request = request) } \ No newline at end of file diff --git a/domain/src/main/java/com/moneymong/moneymong/domain/repository/agency/AgencyRepository.kt b/domain/src/main/java/com/moneymong/moneymong/domain/repository/agency/AgencyRepository.kt index 94762027..6932cc01 100644 --- a/domain/src/main/java/com/moneymong/moneymong/domain/repository/agency/AgencyRepository.kt +++ b/domain/src/main/java/com/moneymong/moneymong/domain/repository/agency/AgencyRepository.kt @@ -5,6 +5,10 @@ import com.moneymong.moneymong.model.agency.AgencyGetResponse import com.moneymong.moneymong.model.agency.AgencyJoinRequest import com.moneymong.moneymong.model.agency.AgencyJoinResponse import com.moneymong.moneymong.model.agency.AgencyRegisterRequest +import com.moneymong.moneymong.model.agency.CategoryCreateRequest +import com.moneymong.moneymong.model.agency.CategoryCreateResponse +import com.moneymong.moneymong.model.agency.CategoryDeleteRequest +import com.moneymong.moneymong.model.agency.CategoryReadResponse import com.moneymong.moneymong.model.agency.MyAgencyResponse import com.moneymong.moneymong.model.agency.RegisterAgencyResponse import kotlinx.coroutines.flow.Flow @@ -15,7 +19,9 @@ interface AgencyRepository { suspend fun fetchMyAgencyList(): Result> suspend fun fetchAgencyByName(agencyName: String): Result> suspend fun agencyCodeNumbers(data: AgencyJoinRequest): Result - suspend fun saveAgencyId(agencyId: Int) suspend fun fetchAgencyId(): Int + suspend fun createCategory(request: CategoryCreateRequest): Result + suspend fun fetchCategories(agencyId: Long): Result + suspend fun deleteCategory(request: CategoryDeleteRequest): Result } \ No newline at end of file diff --git a/domain/src/main/java/com/moneymong/moneymong/domain/usecase/agency/CreateCategoryUseCase.kt b/domain/src/main/java/com/moneymong/moneymong/domain/usecase/agency/CreateCategoryUseCase.kt new file mode 100644 index 00000000..21594d71 --- /dev/null +++ b/domain/src/main/java/com/moneymong/moneymong/domain/usecase/agency/CreateCategoryUseCase.kt @@ -0,0 +1,13 @@ +package com.moneymong.moneymong.domain.usecase.agency + +import com.moneymong.moneymong.domain.repository.agency.AgencyRepository +import com.moneymong.moneymong.model.agency.CategoryCreateRequest +import com.moneymong.moneymong.model.agency.CategoryCreateResponse +import javax.inject.Inject + +class CreateCategoryUseCase @Inject constructor( + private val agencyRepository: AgencyRepository, +) { + suspend operator fun invoke(request: CategoryCreateRequest): Result = + agencyRepository.createCategory(request = request) +} \ No newline at end of file diff --git a/domain/src/main/java/com/moneymong/moneymong/domain/usecase/agency/DeleteCategoryUseCase.kt b/domain/src/main/java/com/moneymong/moneymong/domain/usecase/agency/DeleteCategoryUseCase.kt new file mode 100644 index 00000000..40594360 --- /dev/null +++ b/domain/src/main/java/com/moneymong/moneymong/domain/usecase/agency/DeleteCategoryUseCase.kt @@ -0,0 +1,12 @@ +package com.moneymong.moneymong.domain.usecase.agency + +import com.moneymong.moneymong.domain.repository.agency.AgencyRepository +import com.moneymong.moneymong.model.agency.CategoryDeleteRequest +import javax.inject.Inject + +class DeleteCategoryUseCase @Inject constructor( + private val agencyRepository: AgencyRepository, +){ + suspend operator fun invoke(request: CategoryDeleteRequest): Result = + agencyRepository.deleteCategory(request = request) +} \ No newline at end of file diff --git a/domain/src/main/java/com/moneymong/moneymong/domain/usecase/agency/FetchCategoriesUseCase.kt b/domain/src/main/java/com/moneymong/moneymong/domain/usecase/agency/FetchCategoriesUseCase.kt new file mode 100644 index 00000000..41501a56 --- /dev/null +++ b/domain/src/main/java/com/moneymong/moneymong/domain/usecase/agency/FetchCategoriesUseCase.kt @@ -0,0 +1,12 @@ +package com.moneymong.moneymong.domain.usecase.agency + +import com.moneymong.moneymong.domain.repository.agency.AgencyRepository +import com.moneymong.moneymong.model.agency.CategoryReadResponse +import javax.inject.Inject + +class FetchCategoriesUseCase @Inject constructor( + private val agencyRepository: AgencyRepository +) { + suspend operator fun invoke(agencyId: Long): Result = + agencyRepository.fetchCategories(agencyId = agencyId) +} \ No newline at end of file diff --git a/feature/ledger/src/main/java/com/moneymong/moneymong/ledger/LedgerScreen.kt b/feature/ledger/src/main/java/com/moneymong/moneymong/ledger/LedgerScreen.kt index 2571ac87..3836dc84 100644 --- a/feature/ledger/src/main/java/com/moneymong/moneymong/ledger/LedgerScreen.kt +++ b/feature/ledger/src/main/java/com/moneymong/moneymong/ledger/LedgerScreen.kt @@ -85,16 +85,10 @@ fun LedgerScreen( ) val analyticsTracker = LocalAnalyticsTracker.current - LaunchedEffect(Unit) { - viewModel.fetchMyAgencyList() - viewModel.fetchAgencyMemberList() - viewModel.fetchAgencyExistLedger() - } - viewModel.collectSideEffect { when (it) { is LedgerSideEffect.LedgerNavigateToLedgerDetail -> { - navigateToLedgerDetail(null, it.id, state.isStaff) + navigateToLedgerDetail(null, it.id, it.isStaff) } is LedgerSideEffect.LedgerNavigateToLedgerManual -> { @@ -236,7 +230,8 @@ fun LedgerScreen( onClickTransactionItem = { viewModel.eventEmit( LedgerSideEffect.LedgerNavigateToLedgerDetail( - it + id = it, + isStaff = state.isStaff, ) ) }, diff --git a/feature/ledger/src/main/java/com/moneymong/moneymong/ledger/LedgerSideEffect.kt b/feature/ledger/src/main/java/com/moneymong/moneymong/ledger/LedgerSideEffect.kt index 8dda7faf..a9d9e012 100644 --- a/feature/ledger/src/main/java/com/moneymong/moneymong/ledger/LedgerSideEffect.kt +++ b/feature/ledger/src/main/java/com/moneymong/moneymong/ledger/LedgerSideEffect.kt @@ -7,7 +7,7 @@ sealed class LedgerSideEffect : SideEffect { data object LedgerCloseSheet : LedgerSideEffect() data object LedgerNavigateToLedgerManual : LedgerSideEffect() data object LedgerFetchRetry : LedgerSideEffect() - data class LedgerNavigateToLedgerDetail(val id: Int): LedgerSideEffect() + data class LedgerNavigateToLedgerDetail(val id: Int, val isStaff: Boolean): LedgerSideEffect() data class LedgerSelectedAgencyChange(val agencyId: Int): LedgerSideEffect() data class LedgerVisibleSnackbar(val message: String, val withDismissAction: Boolean): LedgerSideEffect() } diff --git a/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailScreen.kt b/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailScreen.kt index 8153008d..8cfa8688 100644 --- a/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailScreen.kt +++ b/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailScreen.kt @@ -9,6 +9,9 @@ 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.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 @@ -26,9 +29,12 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -53,6 +59,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.indicator.LoadingScreen import com.moneymong.moneymong.design_system.component.modal.MDSModal +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 @@ -74,10 +81,12 @@ import com.moneymong.moneymong.design_system.theme.White import com.moneymong.moneymong.ledgerdetail.view.LedgerDetailTopbarView import com.moneymong.moneymong.ui.DottedShape import com.moneymong.moneymong.ui.noRippleClickable +import com.moneymong.moneymong.ledgerdetail.view.LedgerDetailCategoryBottomSheet +import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect -@OptIn(ExperimentalGlideComposeApi::class) +@OptIn(ExperimentalGlideComposeApi::class, ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable fun LedgerDetailScreen( modifier: Modifier = Modifier, @@ -89,6 +98,8 @@ fun LedgerDetailScreen( val state = viewModel.collectAsState().value val verticalScrollState = rememberScrollState() val focusManager = LocalFocusManager.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() val singlePhotoPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), @@ -156,6 +167,25 @@ fun LedgerDetailScreen( } } + if (state.showBottomSheet) { + LedgerDetailCategoryBottomSheet( + sheetState = sheetState, + categories = state.categories, + categoryValue = state.categoryValue, + isSystemCategoryError = state.isSystemCategoryError, + onDismissRequest = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + viewModel.onDismissBottomSheet() + } + }, + onChangeCategoryValue = viewModel::onChangeCategoryValue, + onCategoryCreate = viewModel::createCategory, + onCategoryDelete = viewModel::deleteCategory, + ) + } + if (state.showConfirmModal) { MDSModal( icon = R.drawable.ic_warning_filled, @@ -177,6 +207,36 @@ fun LedgerDetailScreen( onClickDelete = { viewModel.onChangeVisibleConfirmModal(true) }, onClickDone = viewModel::onClickEditButton ) + }, + bottomBar = { + if (state.isStaff) { + DisposableEffect(key1 = Unit) { + SystemBarColorController.setNavigationBarColor(color = White) + + onDispose { + SystemBarColorController.initialSystemBarColors() + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(White) + .padding(horizontal = MMHorizontalSpacing), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(20.dp)) + MDSButton( + modifier = Modifier.fillMaxWidth(), + text = "수정하기", + enabled = state.enabledEdit, + size = MDSButtonSize.LARGE, + type = MDSButtonType.PRIMARY, + onClick = viewModel::onClickEditButton + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } } ) { Column( @@ -384,6 +444,56 @@ fun LedgerDetailScreen( .height(1.dp) .background(Gray03, shape = DottedShape(8.dp)) ) + if (state.useEditMode) { + 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), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + state.categories.forEach { category -> + val isSelected = category == state.selectedCategory + MDSOutlineTag( + text = category.name, + selected = isSelected, + onClick = { viewModel.onClickCategory(category) }, + ) + } + } + } else { + Text( + text = "카테고리", + style = Body2, + color = Gray06 + ) + Spacer(modifier = Modifier.height(8.dp)) + state.ledgerTransactionDetail?.category?.let { + MDSOutlineTag(text = it) + } + } + Box( + modifier = Modifier + .padding(vertical = 20.dp) + .fillMaxWidth() + .height(1.dp) + .background(Gray03, shape = DottedShape(8.dp)) + ) Text( text = "사진 첨부 (최대12장)", style = Body2, @@ -467,34 +577,6 @@ fun LedgerDetailScreen( } } Spacer(modifier = Modifier.height(20.dp)) - if (state.isStaff) { - DisposableEffect(key1 = Unit) { - SystemBarColorController.setNavigationBarColor(color = White) - - onDispose { - SystemBarColorController.initialSystemBarColors() - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .background(White) - .padding(horizontal = MMHorizontalSpacing), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(20.dp)) - MDSButton( - modifier = Modifier.fillMaxWidth(), - text = "수정하기", - enabled = state.enabledEdit, - size = MDSButtonSize.LARGE, - type = MDSButtonType.PRIMARY, - onClick = viewModel::onClickEditButton - ) - Spacer(modifier = Modifier.height(12.dp)) - } - } } if (state.isLoading) { LoadingScreen(modifier = Modifier.fillMaxSize()) diff --git a/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailState.kt b/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailState.kt index fb6dccf3..dae2141a 100644 --- a/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailState.kt +++ b/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailState.kt @@ -5,6 +5,7 @@ import com.moneymong.moneymong.android.State import com.moneymong.moneymong.common.util.toDateFormat import com.moneymong.moneymong.ui.toWonFormat import com.moneymong.moneymong.design_system.component.textfield.util.PriceType +import com.moneymong.moneymong.model.agency.CategoryResponse import com.moneymong.moneymong.model.ledger.FundType import com.moneymong.moneymong.model.ledgerdetail.LedgerTransactionDetailResponse import java.time.LocalDateTime @@ -32,9 +33,17 @@ data class LedgerDetailState( val showConfirmModal: Boolean = false, val showErrorDialog: Boolean = false, val errorMessage: String = "", - val isStaff: Boolean = false + val isStaff: Boolean = false, + val agencyId: Int = 0, + val categories: List = emptyList(), + val selectedCategory: CategoryResponse? = null, + val showBottomSheet: Boolean = false, + val categoryValue: TextFieldValue = TextFieldValue() ) : State { + val isSystemCategoryError: Boolean + get() = categoryValue.text == SYSTEM_CATEGORY + val fundTypeText: String get() = ledgerTransactionDetail?.fundType?.let { if (it == FundType.INCOME.name) "수입" else "지출" @@ -73,4 +82,9 @@ data class LedgerDetailState( val hasPaymentDate = !isPaymentDateError && paymentDateValue.text.isNotEmpty() val hasPaymentTime = !isPaymentTimeError && paymentTimeValue.text.isNotEmpty() return hasStoreName && hasTotalPrice && hasPaymentDate && hasPaymentTime && !isMemoError - }} + } + + companion object { + private const val SYSTEM_CATEGORY = "카테고리 없음" + } +} diff --git a/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailViewModel.kt b/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailViewModel.kt index d9dc9c93..e656b2a6 100644 --- a/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailViewModel.kt +++ b/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/LedgerDetailViewModel.kt @@ -9,6 +9,10 @@ import com.moneymong.moneymong.android.util.toMultipart import com.moneymong.moneymong.ui.isValidPaymentDate import com.moneymong.moneymong.ui.isValidPaymentTime import com.moneymong.moneymong.ui.validateValue +import com.moneymong.moneymong.domain.usecase.agency.CreateCategoryUseCase +import com.moneymong.moneymong.domain.usecase.agency.DeleteCategoryUseCase +import com.moneymong.moneymong.domain.usecase.agency.FetchAgencyIdUseCase +import com.moneymong.moneymong.domain.usecase.agency.FetchCategoriesUseCase import com.moneymong.moneymong.domain.usecase.ledgerdetail.FetchLedgerTransactionDetailUseCase import com.moneymong.moneymong.domain.usecase.ledgerdetail.DeleteLedgerDetailUseCase import com.moneymong.moneymong.domain.usecase.ledgerdetail.DeleteLedgerDocumentTransactionUseCase @@ -16,6 +20,9 @@ import com.moneymong.moneymong.domain.usecase.ledgerdetail.PostLedgerDocumentTra import com.moneymong.moneymong.domain.usecase.ledgerdetail.UpdateLedgerTransactionDetailUseCase import com.moneymong.moneymong.domain.usecase.ocr.PostFileUploadUseCase import com.moneymong.moneymong.ledgerdetail.navigation.LedgerDetailArgs +import com.moneymong.moneymong.model.agency.CategoryCreateRequest +import com.moneymong.moneymong.model.agency.CategoryDeleteRequest +import com.moneymong.moneymong.model.agency.CategoryResponse import com.moneymong.moneymong.model.ledgerdetail.LedgerDocumentRequest import com.moneymong.moneymong.model.ledgerdetail.LedgerTransactionDetailRequest import com.moneymong.moneymong.model.ledgerdetail.LedgerTransactionDetailResponse @@ -40,11 +47,16 @@ class LedgerDetailViewModel @Inject constructor( private val postLedgerDocumentTransactionUseCase: PostLedgerDocumentTransactionUseCase, private val deleteLedgerDocumentTransactionUseCase: DeleteLedgerDocumentTransactionUseCase, private val postFileUploadUseCase: PostFileUploadUseCase, - private val deleteLedgerDetailUseCase: DeleteLedgerDetailUseCase + private val deleteLedgerDetailUseCase: DeleteLedgerDetailUseCase, + private val fetchAgencyIdUseCase: FetchAgencyIdUseCase, + private val fetchCategoriesUseCase: FetchCategoriesUseCase, + private val createCategoryUseCase: CreateCategoryUseCase, + private val deleteCategoryUseCase: DeleteCategoryUseCase, ) : BaseViewModel(LedgerDetailState()) { init { onChangeStaffStatus(isStaff = LedgerDetailArgs(savedStateHandle).isStaff) + fetchAgencyId() } fun ledgerTransactionEdit(detailId: Int) = intent { @@ -78,7 +90,8 @@ class LedgerDetailViewModel @Inject constructor( storeInfo = state.storeNameValue.text, amount = state.totalPriceValue.text.toInt(), description = state.memoValue.text, - paymentDate = state.formattedPaymentDate + paymentDate = state.formattedPaymentDate, + category = state.selectedCategory?.name, ) updateLedgerTransactionDetailUseCase(detailId, request) .onSuccess { @@ -191,7 +204,8 @@ class LedgerDetailViewModel @Inject constructor( ), memoValue = state.memoValue.copy(text = ledgerTransactionDetail.description), documentList = ledgerTransactionDetail.documentImageUrls.map { it.documentImageUrl }, - documentIdList = emptyList() + documentIdList = emptyList(), + selectedCategory = state.categories.find { it.name == ledgerTransactionDetail.category } ) } } @@ -268,11 +282,72 @@ class LedgerDetailViewModel @Inject constructor( reduce { state.copy(showErrorDialog = visible) } } + private fun fetchAgencyId() = blockingIntent { + val agencyId = fetchAgencyIdUseCase() + reduce { state.copy(agencyId = agencyId) } + fetchCategories() + } + + fun fetchCategories() = intent { + fetchCategoriesUseCase(agencyId = state.agencyId.toLong()) + .onSuccess { + reduce { state.copy(categories = it.categories) } + } + } + + fun createCategory() = intent { + val request = CategoryCreateRequest( + agencyId = state.agencyId.toLong(), + name = state.categoryValue.text + ) + createCategoryUseCase(request) + .onSuccess { + fetchCategories() + reduce { state.copy(showBottomSheet = false, categoryValue = TextFieldValue()) } + }.onFailure { + showErrorDialog(it.message) + } + } + + fun deleteCategory(category: CategoryResponse) = intent { + val request = CategoryDeleteRequest(categoryId = category.id) + deleteCategoryUseCase(request = request) + .onSuccess { fetchCategories() } + .onFailure { + showErrorDialog(it.message) + } + } + + fun onClickCategory(category: CategoryResponse) = intent { + reduce { + if (state.selectedCategory == category) { + state.copy(selectedCategory = null) + } else { + state.copy(selectedCategory = category) + } + } + } + + fun onClickCategoryEdit() = intent { + reduce { state.copy(showBottomSheet = true, categoryValue = TextFieldValue()) } + } + + fun onDismissBottomSheet() = intent { + reduce { state.copy(showBottomSheet = false, categoryValue = TextFieldValue()) } + } + + fun onChangeCategoryValue(value: TextFieldValue) = blockingIntent { + val validate = value.text.validateValue(length = 10) + if (validate) { + reduce { state.copy(categoryValue = value) } + } + } + private fun onChangeStaffStatus(isStaff: Boolean) = intent { reduce { state.copy(isStaff = isStaff) } } - private fun showErrorDialog(message: String?) = intent { + private fun showErrorDialog(message: String?) = blockingIntent { reduce { state.copy( showErrorDialog = true, diff --git a/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/view/LedgerDetailCategoryBottomSheet.kt b/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/view/LedgerDetailCategoryBottomSheet.kt new file mode 100644 index 00000000..b0274fa0 --- /dev/null +++ b/feature/ledgerdetail/src/main/java/com/moneymong/moneymong/ledgerdetail/view/LedgerDetailCategoryBottomSheet.kt @@ -0,0 +1,292 @@ +package com.moneymong.moneymong.ledgerdetail.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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.unit.dp +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.MMHorizontalSpacing +import com.moneymong.moneymong.design_system.theme.White +import com.moneymong.moneymong.model.agency.CategoryResponse +import com.moneymong.moneymong.ui.noRippleClickable + +private enum class BottomSheetType { + LIST, + CREATE, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LedgerDetailCategoryBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + categories: List?, + categoryValue: TextFieldValue, + isSystemCategoryError: Boolean, + onDismissRequest: () -> Unit, + onChangeCategoryValue: (TextFieldValue) -> Unit, + onCategoryCreate: () -> Unit, + onCategoryDelete: (CategoryResponse) -> Unit, +) { + var sheetType by remember { mutableStateOf(BottomSheetType.LIST) } + + MDSBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + AnimatedContent( + targetState = sheetType, + transitionSpec = { + when (targetState) { + BottomSheetType.CREATE -> { + slideInHorizontally { fullWidth -> fullWidth } + .togetherWith(slideOutHorizontally { fullWidth -> fullWidth / -3 }) + } + + BottomSheetType.LIST -> { + slideInHorizontally { fullWidth -> -fullWidth } + .togetherWith(slideOutHorizontally { fullWidth -> fullWidth / 3 }) + } + } + } + ) { targetState -> + when (targetState) { + BottomSheetType.LIST -> { + CategoryListContent( + categories = categories, + onDismissRequest = onDismissRequest, + onClickCreate = { sheetType = BottomSheetType.CREATE }, + onClickDelete = onCategoryDelete, + ) + } + + BottomSheetType.CREATE -> { + CategoryCreateContent( + textFieldValue = categoryValue, + isSystemCategoryError = isSystemCategoryError, + categories = categories, + onValueChange = onChangeCategoryValue, + onClickRegister = onCategoryCreate, + onPrev = { + sheetType = BottomSheetType.LIST + onChangeCategoryValue(TextFieldValue()) + } + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun CategoryListContent( + modifier: Modifier = Modifier, + categories: List?, + onDismissRequest: () -> Unit, + onClickCreate: () -> Unit, + onClickDelete: (CategoryResponse) -> Unit, +) { + val scrollState = rememberScrollState() + 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( + modifier = Modifier.verticalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + categories?.forEach { + MDSOutlineTag( + text = it.name, + iconResource = R.drawable.ic_close_default, + onClick = { onClickDelete(it) } + ) + } + } + } +} + +@Composable +private fun CategoryCreateContent( + modifier: Modifier = Modifier, + textFieldValue: TextFieldValue, + isSystemCategoryError: Boolean, + categories: List?, + 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?.any { it.name == textFieldValue.text } ?: false + 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, + ) + } +} 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..3a9af890 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,16 @@ 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.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 @@ -60,6 +65,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 +74,24 @@ 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 +111,8 @@ fun LedgerManualScreen( } } ) + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() viewModel.collectSideEffect { when (it) { @@ -168,6 +182,25 @@ fun LedgerManualScreen( ) } + if (state.showBottomSheet) { + LedgerManualCategoryBottomSheet( + sheetState = sheetState, + categories = state.categories, + categoryValue = state.categoryValue, + isSystemCategoryError = state.isSystemCategoryError, + onDismissRequest = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + viewModel.onDismissBottomSheet() + } + }, + onChangeCategoryValue = viewModel::onChangeCategoryValue, + onCategoryCreate = viewModel::createCategory, + onCategoryDelete = viewModel::deleteCategory, + ) + } + Scaffold( topBar = { LedgerManualTopbarView( @@ -311,6 +344,38 @@ 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), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + state.categories.forEach { category -> + val isSelected = category == state.selectedCategory + MDSOutlineTag( + text = category.name, + selected = isSelected, + onClick = { viewModel.onClickCategory(category) }, + ) + } + } + 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..c8001f71 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 @@ -4,6 +4,7 @@ import androidx.compose.ui.text.input.TextFieldValue import com.moneymong.moneymong.android.State import com.moneymong.moneymong.common.util.toZonedDateTime import com.moneymong.moneymong.design_system.component.textfield.util.PriceType +import com.moneymong.moneymong.model.agency.CategoryResponse import com.moneymong.moneymong.model.ledger.FundType import java.text.SimpleDateFormat @@ -25,7 +26,11 @@ 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(), + val categories: List = emptyList(), + val selectedCategory: CategoryResponse? = null ) : State { val enabled: Boolean @@ -53,4 +58,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..01bf977f 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 @@ -3,13 +3,20 @@ package com.moneymong.moneymong.ledgermanual import androidx.compose.ui.text.input.TextFieldValue import com.moneymong.moneymong.android.BaseViewModel import com.moneymong.moneymong.android.util.toMultipart +import com.moneymong.moneymong.common.error.MoneyMongError +import com.moneymong.moneymong.domain.usecase.agency.CreateCategoryUseCase +import com.moneymong.moneymong.domain.usecase.agency.DeleteCategoryUseCase import com.moneymong.moneymong.ui.isValidPaymentDate import com.moneymong.moneymong.ui.isValidPaymentTime import com.moneymong.moneymong.ui.validateValue import com.moneymong.moneymong.domain.usecase.agency.FetchAgencyIdUseCase +import com.moneymong.moneymong.domain.usecase.agency.FetchCategoriesUseCase import com.moneymong.moneymong.domain.usecase.ledger.PostLedgerTransactionUseCase import com.moneymong.moneymong.domain.usecase.ocr.PostFileUploadUseCase import com.moneymong.moneymong.domain.usecase.user.FetchUserNicknameUseCase +import com.moneymong.moneymong.model.agency.CategoryCreateRequest +import com.moneymong.moneymong.model.agency.CategoryDeleteRequest +import com.moneymong.moneymong.model.agency.CategoryResponse import com.moneymong.moneymong.model.ledger.FundType import com.moneymong.moneymong.model.ledger.LedgerTransactionRequest import com.moneymong.moneymong.model.ocr.FileUploadRequest @@ -27,11 +34,15 @@ class LedgerManualViewModel @Inject constructor( private val postLedgerTransactionUseCase: PostLedgerTransactionUseCase, private val postFileUploadUseCase: PostFileUploadUseCase, private val fetchAgencyIdUseCase: FetchAgencyIdUseCase, - private val fetchUserNicknameUseCase: FetchUserNicknameUseCase + private val fetchUserNicknameUseCase: FetchUserNicknameUseCase, + private val createCategoryUseCase: CreateCategoryUseCase, + private val fetchCategoriesUseCase: FetchCategoriesUseCase, + private val deleteCategoryUseCase: DeleteCategoryUseCase, ) : BaseViewModel(LedgerManualState()) { init { fetchUserInfo() + fetchCategories() } @OptIn(OrbitExperimental::class) @@ -56,6 +67,7 @@ class LedgerManualViewModel @Inject constructor( description = state.memoValue.text.ifEmpty { "내용 없음" }, paymentDate = state.postPaymentDate, documentImageUrls = state.documentList, + category = state.selectedCategory?.name, ) postLedgerTransactionUseCase(state.agencyId, ledgerTransactionRequest) .onSuccess { @@ -92,6 +104,51 @@ class LedgerManualViewModel @Inject constructor( } } + fun createCategory() = intent { + val request = + CategoryCreateRequest( + agencyId = state.agencyId.toLong(), + name = state.categoryValue.text + ) + + createCategoryUseCase(request) + .onSuccess { + fetchCategories() + reduce { state.copy(showBottomSheet = false, categoryValue = TextFieldValue()) } + }.onFailure { + reduce { + state.copy( + showErrorDialog = true, + errorMessage = it.message ?: MoneyMongError.UnExpectedError.message + ) + } + } + } + + fun fetchCategories() = intent { + fetchCategoriesUseCase(agencyId = state.agencyId.toLong()) + .onSuccess { + reduce { + state.copy(categories = it.categories) + } + } + } + + fun deleteCategory(category: CategoryResponse) = intent { + val request = CategoryDeleteRequest(categoryId = category.id) + + deleteCategoryUseCase(request = request) + .onSuccess { fetchCategories() } + .onFailure { + reduce { + state.copy( + showErrorDialog = true, + errorMessage = it.message ?: MoneyMongError.UnExpectedError.message + ) + } + } + } + fun onChangeStoreNameValue(value: TextFieldValue) = blockingIntent { val validate = value.text.validateValue(length = 20) if (!validate) { @@ -177,6 +234,28 @@ class LedgerManualViewModel @Inject constructor( fun onClickErrorDialogConfirm() = eventEmit(LedgerManualSideEffect.LedgerManualHideErrorDialog) + fun onClickCategoryEdit() = intent { reduce { state.copy(showBottomSheet = true, categoryValue = TextFieldValue()) } } + + fun onDismissBottomSheet() = intent { reduce { state.copy(showBottomSheet = false, categoryValue = TextFieldValue()) } } + + fun onChangeCategoryValue(value: TextFieldValue) = blockingIntent { + val validate = value.text.validateValue(length = 10) + + if (validate) { + reduce { state.copy(categoryValue = value) } + } + } + + fun onClickCategory(category: CategoryResponse) = intent { + reduce { + if (state.selectedCategory == category) { + state.copy(selectedCategory = null) + } else { + state.copy(selectedCategory = category) + } + } + } + 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..83c9742f --- /dev/null +++ b/feature/ledgermanual/src/main/java/com/moneymong/moneymong/ledgermanual/view/LedgerManualCategoryBottomSheet.kt @@ -0,0 +1,313 @@ +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.model.agency.CategoryResponse +import com.moneymong.moneymong.ui.noRippleClickable + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LedgerManualCategoryBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + categories: List?, + categoryValue: TextFieldValue, + isSystemCategoryError: Boolean, + onDismissRequest: () -> Unit, + onChangeCategoryValue: (TextFieldValue) -> Unit, + onCategoryCreate: () -> Unit, + onCategoryDelete: (CategoryResponse) -> 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 }, + onClickDelete = onCategoryDelete, + ) + } + + LedgerManualBottomSheetType.CREATE -> { + LedgerManualCategoryCreateBottomSheetContent( + textFieldValue = categoryValue, + isSystemCategoryError = isSystemCategoryError, + categories = categories, + onValueChange = onChangeCategoryValue, + onClickRegister = onCategoryCreate, + onPrev = { + sheetType = LedgerManualBottomSheetType.LIST + onChangeCategoryValue(TextFieldValue()) + } + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LedgerManualCategoryBottomSheetContent( + modifier: Modifier = Modifier, + categories: List?, + onDismissRequest: () -> Unit, + onClickCreate: () -> Unit, + onClickDelete: (CategoryResponse) -> Unit, +) { + val scrollState = rememberScrollState() + 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( + modifier = Modifier.verticalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + categories?.forEach { + MDSOutlineTag( + text = it.name, + iconResource = R.drawable.ic_close_default, + onClick = { onClickDelete(it) } + ) + } + } + } +} + +@Composable +fun LedgerManualCategoryCreateBottomSheetContent( + modifier: Modifier = Modifier, + textFieldValue: TextFieldValue, + isSystemCategoryError: Boolean, + categories: List?, + 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?.any { it.name == textFieldValue.text } ?: false + 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(CategoryResponse(1L, "testTooLongTextOverFlow"), CategoryResponse(1L, "test")) + + LedgerManualCategoryBottomSheetContent( + categories = categories, + onDismissRequest = {}, + onClickCreate = {}, + ) {} +} + +@Preview(showBackground = true) +@Composable +fun LedgerManualCategoryCreateBottomSheetContentPreview() { + LedgerManualCategoryCreateBottomSheetContent( + textFieldValue = TextFieldValue(), + isSystemCategoryError = false, + categories = emptyList(), + onValueChange = {}, + onClickRegister = {} + ) {} +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index f064f25c..8af5f564 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,8 +11,8 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven(url = "https://devrepo.kakao.com/nexus/content/groups/public/") maven(url = "https://jitpack.io") - maven (url = "https://devrepo.kakao.com/nexus/content/groups/public/" ) } }