diff --git a/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt b/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt index fdf8411..94d0f63 100644 --- a/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt +++ b/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt @@ -1,5 +1,8 @@ package com.sampoom.android.app.navigation +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -23,6 +26,8 @@ import com.sampoom.android.R import com.sampoom.android.feature.auth.ui.LoginScreen import com.sampoom.android.feature.auth.ui.SignUpScreen import com.sampoom.android.feature.cart.ui.CartListScreen +import com.sampoom.android.feature.order.ui.OrderDetailScreen +import com.sampoom.android.feature.order.ui.OrderListScreen import com.sampoom.android.feature.outbound.ui.OutboundListScreen import com.sampoom.android.feature.part.ui.PartListScreen import com.sampoom.android.feature.part.ui.PartScreen @@ -41,6 +46,8 @@ const val ROUTE_ORDERS = "orders" const val ROUTE_PARTS = "parts" const val ROUTE_PART_LIST = "parts/{agencyId}/group/{groupId}" fun routePartList(agencyId: Long, groupId: Long): String = "parts/$agencyId/group/$groupId" +const val ROUTE_ORDER_DETAIL = "orders/{agencyId}/orders/{orderId}" +fun routeOrderDetail(agencyId: Long, orderId: Long): String = "orders/$agencyId/orders/$orderId" const val ROUTE_EMPLOYEE = "employee" const val ROUTE_SETTINGS = "settings" @@ -55,6 +62,7 @@ sealed class BottomNavItem( object Orders : BottomNavItem(ROUTE_ORDERS, R.string.nav_order, R.drawable.orders) } +@RequiresApi(Build.VERSION_CODES.O) @Composable fun AppNavHost() { val navController = rememberNavController() @@ -96,6 +104,7 @@ fun AppNavHost() { navController.navigateUp() }, onNavigatePartList = { group -> + // TODO: 실제 사용자의 agencyId 사용 navController.navigate(routePartList(1, group.id)) } ) @@ -113,9 +122,23 @@ fun AppNavHost() { } ) } + composable( + ROUTE_ORDER_DETAIL, + arguments = listOf( + navArgument("agencyId") { type = NavType.LongType }, + navArgument("orderId") { type = NavType.LongType } + ) + ) { + OrderDetailScreen( + onNavigateBack = { + navController.navigateUp() + } + ) + } } } +@RequiresApi(Build.VERSION_CODES.O) @Composable fun MainScreen( parentNavController: NavHostController @@ -128,13 +151,32 @@ fun MainScreen( ) { innerPadding -> NavHost( navController = navController, - startDestination = ROUTE_DASHBOARD, - modifier = Modifier.padding(innerPadding) + startDestination = ROUTE_DASHBOARD ) { - composable(ROUTE_DASHBOARD) { DashboardScreen() } - composable(ROUTE_OUTBOUND) { OutboundListScreen() } - composable(ROUTE_CART) { CartListScreen() } - composable(ROUTE_ORDERS) { OrderScreen() } + composable(ROUTE_DASHBOARD) { + DashboardScreen( + paddingValues = innerPadding + ) + } + composable(ROUTE_OUTBOUND) { + OutboundListScreen( + paddingValues = innerPadding + ) + } + composable(ROUTE_CART) { + CartListScreen( + paddingValues = innerPadding + ) + } + composable(ROUTE_ORDERS) { + OrderListScreen( + paddingValues = innerPadding, + onNavigateOrderDetail = { order -> + // TODO: 실제 사용자의 agencyId 사용 + parentNavController.navigate(routeOrderDetail(1, order.orderId)) + } + ) + } } } } @@ -198,13 +240,9 @@ fun BottomNavigationBar(navController: NavHostController) { // 임시 화면들 (실제로는 각각의 feature 모듈에서 구현) @Composable -private fun DashboardScreen() { +private fun DashboardScreen( + paddingValues: PaddingValues +) { // 홈 화면 구현 - Text("대시보드 화면") -} - -@Composable -private fun OrderScreen() { - // 프로필 화면 구현 - Text("Order 화면") + Text("대시보드 화면", modifier = Modifier.padding(paddingValues)) } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/network/ErrorHandling.kt b/app/src/main/java/com/sampoom/android/core/network/ErrorHandling.kt index 23d3ae5..e0a43ca 100644 --- a/app/src/main/java/com/sampoom/android/core/network/ErrorHandling.kt +++ b/app/src/main/java/com/sampoom/android/core/network/ErrorHandling.kt @@ -1,5 +1,6 @@ package com.sampoom.android.core.network +import android.util.Log import com.google.gson.Gson import com.google.gson.JsonSyntaxException import retrofit2.HttpException @@ -12,6 +13,7 @@ data class ApiErrorResponse( fun Throwable.serverMessageOrNull(): String? { if (this is HttpException) { val errorBody = response()?.errorBody()?.string() ?: return null + Log.d("ErrorHandling", "Error body: $errorBody") return try { Gson().fromJson(errorBody, ApiErrorResponse::class.java).message } catch (_: JsonSyntaxException) { diff --git a/app/src/main/java/com/sampoom/android/core/ui/component/StatusChip.kt b/app/src/main/java/com/sampoom/android/core/ui/component/StatusChip.kt new file mode 100644 index 0000000..47f0272 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/component/StatusChip.kt @@ -0,0 +1,37 @@ +package com.sampoom.android.core.ui.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sampoom.android.R +import com.sampoom.android.core.ui.theme.FailRed +import com.sampoom.android.core.ui.theme.SuccessGreen +import com.sampoom.android.core.ui.theme.WaitYellow +import com.sampoom.android.feature.order.domain.model.OrderStatus + +@Composable +fun StatusChip(status: OrderStatus) { + val (text, color) = when (status) { + OrderStatus.PENDING -> stringResource(R.string.order_status_pending) to WaitYellow + OrderStatus.COMPLETED -> stringResource(R.string.order_status_completed) to SuccessGreen + OrderStatus.CANCELED -> stringResource(R.string.order_status_canceled) to FailRed + } + + Surface( + shape = RoundedCornerShape(16.dp), + color = color.copy(alpha = 0.2F) + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = color + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/util/FormatDate.kt b/app/src/main/java/com/sampoom/android/core/util/FormatDate.kt new file mode 100644 index 0000000..7684ba3 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/util/FormatDate.kt @@ -0,0 +1,29 @@ +package com.sampoom.android.core.util + +import android.os.Build +import androidx.annotation.RequiresApi +import java.time.format.DateTimeFormatter + +@RequiresApi(Build.VERSION_CODES.O) +fun formatDate(dateString: String): String { + return runCatching { + val out = DateTimeFormatter.ISO_LOCAL_DATE + val hasOffset = + dateString.contains("Z") || (dateString.contains('T') && (dateString.lastIndexOf('+') > dateString.indexOf( + 'T' + ) || (dateString.lastIndexOf('-') > dateString.indexOf('T') && dateString.count { it == ':' } >= 3))) + val date = if (hasOffset) { + val inFmt = DateTimeFormatter.ISO_OFFSET_DATE_TIME + java.time.OffsetDateTime.parse(dateString, inFmt).toLocalDate() + } else { + val inFmt = java.time.format.DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd'T'HH:mm:ss") + .optionalStart() + .appendFraction(java.time.temporal.ChronoField.NANO_OF_SECOND, 0, 6, true) + .optionalEnd() + .toFormatter(java.util.Locale.ROOT) + java.time.LocalDateTime.parse(dateString, inFmt).toLocalDate() + } + date.format(out) + }.getOrElse { dateString } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/util/OrderTitle.kt b/app/src/main/java/com/sampoom/android/core/util/OrderTitle.kt new file mode 100644 index 0000000..631540a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/util/OrderTitle.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.core.util + +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.model.OrderPart + +fun buildOrderTitle(order: Order): String { + val flattened: List> = + order.items.flatMap { category -> + category.groups.flatMap { group -> + group.parts.map { part -> Triple(category.categoryName, group.groupName, part) } + } + } + + if (flattened.isEmpty()) return "-" + + val first = flattened.first() + val groupName = first.second + val part = first.third + val totalParts = flattened.size + + return if (totalParts == 1) { + "$groupName - ${part.name} ${part.quantity}EA" + } else { + "$groupName - ${part.name} ${part.quantity}EA 외 ${totalParts - 1}건" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/data/remote/api/CartApi.kt b/app/src/main/java/com/sampoom/android/feature/cart/data/remote/api/CartApi.kt index aafff14..06bacec 100644 --- a/app/src/main/java/com/sampoom/android/feature/cart/data/remote/api/CartApi.kt +++ b/app/src/main/java/com/sampoom/android/feature/cart/data/remote/api/CartApi.kt @@ -12,6 +12,7 @@ import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path +// TODO: AgencyId 동적 주입 interface CartApi { // 장바구니 목록 조회 @GET("agency/1/cart") diff --git a/app/src/main/java/com/sampoom/android/feature/cart/data/repository/CartRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/cart/data/repository/CartRepositoryImpl.kt index 70021a1..ddc5d07 100644 --- a/app/src/main/java/com/sampoom/android/feature/cart/data/repository/CartRepositoryImpl.kt +++ b/app/src/main/java/com/sampoom/android/feature/cart/data/repository/CartRepositoryImpl.kt @@ -6,7 +6,9 @@ import com.sampoom.android.feature.cart.data.remote.dto.AddCartRequestDto import com.sampoom.android.feature.cart.data.remote.dto.UpdateCartRequestDto import com.sampoom.android.feature.cart.domain.model.CartList import com.sampoom.android.feature.cart.domain.repository.CartRepository +import kotlinx.coroutines.CancellationException import javax.inject.Inject +import kotlin.Result class CartRepositoryImpl @Inject constructor( private val api: CartApi @@ -21,23 +23,38 @@ class CartRepositoryImpl @Inject constructor( partId: Long, quantity: Long ): Result { - val dto = api.addCart(AddCartRequestDto(partId, quantity)) - return runCatching { + return try { + val dto = api.addCart(AddCartRequestDto(partId, quantity)) if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce: CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) } } override suspend fun deleteCart(cartItemId: Long): Result { - val dto = api.deleteCart(cartItemId) - return runCatching { + return try { + val dto = api.deleteCart(cartItemId) if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce: CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) } } override suspend fun deleteAllCart(): Result { - val dto = api.deleteAllCart() - return runCatching { + return try { + val dto = api.deleteAllCart() if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce: CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) } } @@ -45,9 +62,14 @@ class CartRepositoryImpl @Inject constructor( cartItemId: Long, quantity: Long ): Result { - val dto = api.updateCart(cartItemId, UpdateCartRequestDto(quantity)) - return runCatching { + return try { + val dto = api.updateCart(cartItemId, UpdateCartRequestDto(quantity)) if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce: CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) } } } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListScreen.kt b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListScreen.kt index b48d196..bf83548 100644 --- a/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListScreen.kt +++ b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListScreen.kt @@ -1,9 +1,9 @@ package com.sampoom.android.feature.cart.ui -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -20,6 +20,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -28,7 +31,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -45,145 +47,156 @@ import com.sampoom.android.core.ui.theme.backgroundCardColor import com.sampoom.android.core.ui.theme.textColor import com.sampoom.android.core.ui.theme.textSecondaryColor import com.sampoom.android.feature.cart.domain.model.CartPart +import com.sampoom.android.feature.order.ui.OrderListUiEvent +import com.sampoom.android.feature.order.ui.OrderResultBottomSheet import kotlin.collections.forEach @Composable fun CartListScreen( + paddingValues: PaddingValues, viewModel: CartListViewModel = hiltViewModel() ) { val errorLabel = stringResource(R.string.common_error) val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() var showEmptyCartDialog by remember { mutableStateOf(false) } var showConfirmDialog by remember { mutableStateOf(false) } - val context = LocalContext.current - - LaunchedEffect(Unit) { - viewModel.clearSuccess() - } LaunchedEffect(errorLabel) { viewModel.bindLabel(errorLabel) viewModel.onEvent(CartListUiEvent.LoadCartList) } - LaunchedEffect(uiState.isOrderSuccess) { - if (uiState.isOrderSuccess) { - Toast.makeText( - context, - context.getString(R.string.cart_toast_order_text), - Toast.LENGTH_SHORT - ).show() - } - viewModel.clearSuccess() + uiState.processedOrder?.let { orders -> + OrderResultBottomSheet( + order = orders, + onDismiss = { viewModel.onEvent(CartListUiEvent.DismissOrderResult)} + ) } - Column(Modifier.fillMaxSize()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier - .padding(vertical = 16.dp), - text = stringResource(R.string.cart_title), - style = MaterialTheme.typography.titleLarge, - color = textColor() + PullToRefreshBox( + isRefreshing = uiState.cartLoading, + onRefresh = { viewModel.onEvent(CartListUiEvent.LoadCartList) }, + state = pullRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = uiState.cartLoading, + containerColor = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.onPrimaryContainer, + state = pullRefreshState ) + } + ) { + Column(Modifier.fillMaxSize().padding(paddingValues)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .padding(vertical = 16.dp), + text = stringResource(R.string.cart_title), + style = MaterialTheme.typography.titleLarge, + color = textColor() + ) - when { - uiState.cartLoading -> {} - uiState.cartError != null -> {} - uiState.cartList.isEmpty() -> {} - else -> { - TextButton( - onClick = { showEmptyCartDialog = true } - ) { - Text( - text = stringResource(R.string.cart_empty_list), - style = MaterialTheme.typography.titleMedium, - color = FailRed - ) + when { + uiState.cartLoading -> {} + uiState.cartError != null -> {} + uiState.cartList.isEmpty() -> {} + else -> { + TextButton( + onClick = { showEmptyCartDialog = true } + ) { + Text( + text = stringResource(R.string.cart_empty_list), + style = MaterialTheme.typography.titleMedium, + color = FailRed + ) + } } } } - } - - when { - uiState.cartLoading -> { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - uiState.cartError != null -> { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - ErrorContent( - onRetry = { viewModel.onEvent(CartListUiEvent.RetryCartList) }, - modifier = Modifier.height(200.dp) - ) + when { + uiState.cartLoading -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } } - } - uiState.cartList.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - EmptyContent( - message = stringResource(R.string.cart_empty_outbound), - modifier = Modifier.height(200.dp) - ) + uiState.cartError != null -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorContent( + onRetry = { viewModel.onEvent(CartListUiEvent.RetryCartList) }, + modifier = Modifier.height(200.dp) + ) + } } - } - else -> { - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( + uiState.cartList.isEmpty() -> { + Box( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .fillMaxSize(), + contentAlignment = Alignment.Center ) { - uiState.cartList.forEach { category -> - category.groups.forEach { group -> - item { - CartSection( - categoryName = category.categoryName, - groupName = group.groupName, - parts = group.parts, - isUpdating = uiState.isUpdating, - isDeleting = uiState.isDeleting, - onEvent = { viewModel.onEvent(it) } - ) + EmptyContent( + message = stringResource(R.string.cart_empty_outbound), + modifier = Modifier.height(200.dp) + ) + } + } + + else -> { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + uiState.cartList.forEach { category -> + category.groups.forEach { group -> + item { + CartSection( + categoryName = category.categoryName, + groupName = group.groupName, + parts = group.parts, + isUpdating = uiState.isUpdating, + isDeleting = uiState.isDeleting, + onEvent = { viewModel.onEvent(it) } + ) + } } } + item { Spacer(Modifier.height(100.dp)) } } - item { Spacer(Modifier.height(100.dp)) } - } - CommonButton( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomEnd) - .padding(16.dp) - .padding(end = 72.dp), - variant = ButtonVariant.Primary, - size = ButtonSize.Large, - onClick = { showConfirmDialog = true } - ) { Text(stringResource(R.string.cart_order_parts)) } + CommonButton( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomEnd) + .padding(16.dp) + .padding(end = 72.dp), + variant = ButtonVariant.Primary, + size = ButtonSize.Large, + onClick = { showConfirmDialog = true } + ) { Text(stringResource(R.string.cart_order_parts)) } + } } } } diff --git a/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiEvent.kt index f96cb48..15682c9 100644 --- a/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiEvent.kt +++ b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiEvent.kt @@ -9,4 +9,5 @@ sealed interface CartListUiEvent { object DeleteAllCart : CartListUiEvent object ClearUpdateError : CartListUiEvent object ClearDeleteError : CartListUiEvent + object DismissOrderResult : CartListUiEvent } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiState.kt b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiState.kt index d6ae356..d005b17 100644 --- a/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiState.kt +++ b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiState.kt @@ -1,6 +1,7 @@ package com.sampoom.android.feature.cart.ui import com.sampoom.android.feature.cart.domain.model.Cart +import com.sampoom.android.feature.order.domain.model.Order data class CartListUiState( val cartList: List = emptyList(), @@ -11,5 +12,7 @@ data class CartListUiState( val updateError: String? = null, val isDeleting: Boolean = false, val deleteError: String? = null, - val isOrderSuccess: Boolean = false + val isProcessing: Boolean = false, + val processError: String? = null, + val processedOrder: List? = null ) diff --git a/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListViewModel.kt b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListViewModel.kt index 1be0d4f..80506ab 100644 --- a/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListViewModel.kt +++ b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListViewModel.kt @@ -8,7 +8,9 @@ import com.sampoom.android.feature.cart.domain.usecase.DeleteAllCartUseCase import com.sampoom.android.feature.cart.domain.usecase.DeleteCartUseCase import com.sampoom.android.feature.cart.domain.usecase.GetCartUseCase import com.sampoom.android.feature.cart.domain.usecase.UpdateCartQuantityUseCase +import com.sampoom.android.feature.order.domain.usecase.CreateOrderUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -20,7 +22,8 @@ class CartListViewModel @Inject constructor( private val getCartListUseCase: GetCartUseCase, private val updateCartQuantityUseCase: UpdateCartQuantityUseCase, private val deleteCartUseCase: DeleteCartUseCase, - private val deleteAllCartUseCase: DeleteAllCartUseCase + private val deleteAllCartUseCase: DeleteAllCartUseCase, + private val createOrderUseCase: CreateOrderUseCase ) : ViewModel() { private companion object { @@ -50,6 +53,7 @@ class CartListViewModel @Inject constructor( is CartListUiEvent.DeleteAllCart -> deleteAllCart() is CartListUiEvent.ClearUpdateError -> _uiState.update { it.copy(updateError = null) } is CartListUiEvent.ClearDeleteError -> _uiState.update { it.copy(deleteError = null) } + is CartListUiEvent.DismissOrderResult -> _uiState.update { it.copy(processedOrder = null) } } } @@ -57,49 +61,48 @@ class CartListViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(cartLoading = true, cartError = null) } - runCatching { getCartListUseCase() } - .onSuccess { cartList -> - _uiState.update { - it.copy( - cartList = cartList.items, - cartLoading = false, - cartError = null - ) - } + try { + val cartList = getCartListUseCase() + _uiState.update { + it.copy( + cartList = cartList.items, + cartLoading = false, + cartError = null + ) } - .onFailure { throwable -> - val backendMessage = throwable.serverMessageOrNull() - _uiState.update { - it.copy( - cartLoading = false, - cartError = backendMessage ?: (throwable.message ?: errorLabel) - ) - } - + } catch (ce: CancellationException) { + throw ce + } catch (throwable: Throwable) { + val backendMessage = throwable.serverMessageOrNull() + _uiState.update { + it.copy( + cartLoading = false, + cartError = backendMessage ?: (throwable.message ?: errorLabel) + ) } + } Log.d(TAG, "submit: ${_uiState.value}") } } - // TODO() : 주문 생성 로직 private fun processOrder() { viewModelScope.launch { -// _uiState.update { it.copy(cartLoading = true, cartError = null) } -// -// processCartUseCase() -// .onSuccess { -// _uiState.update { it.copy(isUpdating = false, isOrderSuccess = true) } -// loadCartList() -// } -// .onFailure { throwable -> -// val backendMessage = throwable.serverMessageOrNull() -// _uiState.update { -// it.copy( -// isUpdating = false, -// updateError = backendMessage ?: (throwable.message ?: errorLabel) -// ) -// } -// } + _uiState.update { it.copy(isProcessing = true, processError = null) } + + runCatching { createOrderUseCase() } + .onSuccess { orderList -> + _uiState.update { it.copy(isProcessing = false, processedOrder = orderList.items) } + loadCartList() + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + _uiState.update { + it.copy( + isProcessing = false, + processError = backendMessage ?: (throwable.message ?: errorLabel) + ) + } + } Log.d(TAG, "submit: ${_uiState.value}") } } @@ -217,8 +220,4 @@ class CartListViewModel @Inject constructor( currentState.copy(cartList = emptyList()) } } - - fun clearSuccess() { - _uiState.update { it.copy(isOrderSuccess = false) } - } } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/data/mapper/OrderMappers.kt b/app/src/main/java/com/sampoom/android/feature/order/data/mapper/OrderMappers.kt new file mode 100644 index 0000000..fd25e78 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/mapper/OrderMappers.kt @@ -0,0 +1,15 @@ +package com.sampoom.android.feature.order.data.mapper + +import com.sampoom.android.feature.order.data.remote.dto.OrderCategoryDto +import com.sampoom.android.feature.order.data.remote.dto.OrderDto +import com.sampoom.android.feature.order.data.remote.dto.OrderGroupDto +import com.sampoom.android.feature.order.data.remote.dto.OrderPartDto +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.model.OrderCategory +import com.sampoom.android.feature.order.domain.model.OrderGroup +import com.sampoom.android.feature.order.domain.model.OrderPart + +fun OrderDto.toModel(): Order = Order(orderId, orderNumber, createdAt, status, agencyName, items.map { it.toModel() }) +fun OrderCategoryDto.toModel(): OrderCategory = OrderCategory(categoryId, categoryName, groups.map { it.toModel() }) +fun OrderGroupDto.toModel(): OrderGroup = OrderGroup(groupId, groupName, parts.map { it.toModel() }) +fun OrderPartDto.toModel(): OrderPart = OrderPart(partId, code, name, quantity) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/data/remote/api/OrderApi.kt b/app/src/main/java/com/sampoom/android/feature/order/data/remote/api/OrderApi.kt new file mode 100644 index 0000000..6785d0c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/remote/api/OrderApi.kt @@ -0,0 +1,33 @@ +package com.sampoom.android.feature.order.data.remote.api + +import com.sampoom.android.core.network.ApiResponse +import com.sampoom.android.core.network.ApiSuccessResponse +import com.sampoom.android.feature.order.data.remote.dto.OrderDto +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path + +// TODO: AgencyId 동적 주입 +interface OrderApi { + // 주문 목록 조회 + @GET("agency/1/orders") + suspend fun getOrderList(): ApiResponse> + + // 주문 생성 + @POST("agency/1/orders") + suspend fun createOrder(): ApiResponse> + + // 주문 입고 처리 + @PATCH("agency/1/orders/{orderId}/receive") + suspend fun receiveOrder(@Path("orderId") orderId: Long): ApiSuccessResponse + + // 주문 상세 조회 + @GET("agency/1/orders/{orderId}") + suspend fun getOrderDetail(@Path("orderId") orderId: Long): ApiResponse> + + // 주문 취소 + @DELETE("agency/1/orders/{orderId}") + suspend fun cancelOrder(@Path("orderId") orderId: Long): ApiSuccessResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderDto.kt b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderDto.kt new file mode 100644 index 0000000..50a9129 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderDto.kt @@ -0,0 +1,31 @@ +package com.sampoom.android.feature.order.data.remote.dto + +import com.sampoom.android.feature.order.domain.model.OrderStatus + +data class OrderDto( + val orderId: Long, + val orderNumber: String?, + val createdAt: String?, + val status: OrderStatus, + val agencyName: String?, + val items: List +) + +data class OrderCategoryDto( + val categoryId: Long, + val categoryName: String, + val groups: List +) + +data class OrderGroupDto( + val groupId: Long, + val groupName: String, + val parts: List +) + +data class OrderPartDto( + val partId: Long, + val code: String, + val name: String, + val quantity: Long +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/data/repository/OrderRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/order/data/repository/OrderRepositoryImpl.kt new file mode 100644 index 0000000..f43065a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/repository/OrderRepositoryImpl.kt @@ -0,0 +1,54 @@ +package com.sampoom.android.feature.order.data.repository + +import com.sampoom.android.feature.order.data.mapper.toModel +import com.sampoom.android.feature.order.data.remote.api.OrderApi +import com.sampoom.android.feature.order.domain.model.OrderList +import com.sampoom.android.feature.order.domain.repository.OrderRepository +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +class OrderRepositoryImpl @Inject constructor( + private val api: OrderApi +) : OrderRepository { + override suspend fun getOrderList(): OrderList { + val dto = api.getOrderList() + val orderItems = dto.data.map { it.toModel() } + return OrderList(items = orderItems) + } + + override suspend fun createOrder(): OrderList { + val dto = api.createOrder() + val orderItems = dto.data.map { it.toModel() } + return OrderList(items = orderItems) + } + + override suspend fun receiveOrder(orderId: Long): Result { + return try { + val dto = api.receiveOrder(orderId) + if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce : CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) + } + } + + override suspend fun getOrderDetail(orderId: Long): OrderList { + val dto = api.getOrderDetail(orderId) + val orderItems = dto.data.map { it.toModel() } + return OrderList(items = orderItems) + } + + override suspend fun cancelOrder(orderId: Long): Result { + return try { + val dto = api.cancelOrder(orderId) + if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce : CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/di/OrderModules.kt b/app/src/main/java/com/sampoom/android/feature/order/di/OrderModules.kt new file mode 100644 index 0000000..c17f4fb --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/di/OrderModules.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.feature.order.di + +import com.sampoom.android.feature.order.data.remote.api.OrderApi +import com.sampoom.android.feature.order.data.repository.OrderRepositoryImpl +import com.sampoom.android.feature.order.domain.repository.OrderRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class OrderBindModule { + @Binds @Singleton + abstract fun bindOrderRepository(impl: OrderRepositoryImpl): OrderRepository +} + +@Module +@InstallIn(SingletonComponent::class) +object OrderModule { + @Provides @Singleton + fun provideOrderApi(retrofit: Retrofit): OrderApi = retrofit.create(OrderApi::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/model/Order.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/model/Order.kt new file mode 100644 index 0000000..90da7f8 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/model/Order.kt @@ -0,0 +1,29 @@ +package com.sampoom.android.feature.order.domain.model + +data class Order( + val orderId: Long, + val orderNumber: String?, + val createdAt: String?, + val status: OrderStatus, + val agencyName: String?, + val items: List +) + +data class OrderCategory( + val categoryId: Long, + val categoryName: String, + val groups: List +) + +data class OrderGroup( + val groupId: Long, + val groupName: String, + val parts: List +) + +data class OrderPart( + val partId: Long, + val code: String, + val name: String, + val quantity: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/model/OrderList.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/model/OrderList.kt new file mode 100644 index 0000000..94d1431 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/model/OrderList.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.order.domain.model + +data class OrderList( + val items: List, + val totalCount: Int = items.size, + val isEmpty: Boolean = items.isEmpty() +) { + companion object Companion { + fun empty() = OrderList(emptyList()) + } +} diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/model/OrderStatus.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/model/OrderStatus.kt new file mode 100644 index 0000000..211339b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/model/OrderStatus.kt @@ -0,0 +1,16 @@ +package com.sampoom.android.feature.order.domain.model + +import java.util.Locale + +enum class OrderStatus { + PENDING, COMPLETED, CANCELED; + + companion object { + fun from(raw: String?): OrderStatus = when (raw?.uppercase(Locale.ROOT)) { + "PENDING" -> PENDING + "COMPLETED" -> COMPLETED + "CANCELED" -> CANCELED + else -> PENDING + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/repository/OrderRepository.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/repository/OrderRepository.kt new file mode 100644 index 0000000..871b70a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/repository/OrderRepository.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.order.domain.repository + +import com.sampoom.android.feature.order.domain.model.OrderList + +interface OrderRepository { + suspend fun getOrderList(): OrderList + suspend fun createOrder(): OrderList + suspend fun receiveOrder(orderId: Long): Result + suspend fun getOrderDetail(orderId: Long): OrderList + suspend fun cancelOrder(orderId: Long): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CancelOrderUseCase.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CancelOrderUseCase.kt new file mode 100644 index 0000000..e3e8f86 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CancelOrderUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.order.domain.usecase + +import com.sampoom.android.feature.order.domain.repository.OrderRepository +import javax.inject.Inject + +class CancelOrderUseCase @Inject constructor( + private val repository: OrderRepository +){ + suspend operator fun invoke(orderId: Long) = repository.cancelOrder(orderId) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CreateOrderUseCase.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CreateOrderUseCase.kt new file mode 100644 index 0000000..c26aafe --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CreateOrderUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.order.domain.usecase + +import com.sampoom.android.feature.order.domain.repository.OrderRepository +import javax.inject.Inject + +class CreateOrderUseCase @Inject constructor( + private val repository: OrderRepository +){ + suspend operator fun invoke() = repository.createOrder() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/GetOrderDetailUseCase.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/GetOrderDetailUseCase.kt new file mode 100644 index 0000000..1898e1b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/GetOrderDetailUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.order.domain.usecase + +import com.sampoom.android.feature.order.domain.repository.OrderRepository +import javax.inject.Inject + +class GetOrderDetailUseCase @Inject constructor( + private val repository: OrderRepository +) { + suspend operator fun invoke(orderId: Long) = repository.getOrderDetail(orderId) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/GetOrderUseCase.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/GetOrderUseCase.kt new file mode 100644 index 0000000..ad20ec3 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/GetOrderUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.order.domain.usecase + +import com.sampoom.android.feature.order.domain.repository.OrderRepository +import javax.inject.Inject + +class GetOrderUseCase @Inject constructor( + private val repository: OrderRepository +){ + suspend operator fun invoke() = repository.getOrderList() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/ReceiveOrderUseCase.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/ReceiveOrderUseCase.kt new file mode 100644 index 0000000..76ce9b1 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/ReceiveOrderUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.order.domain.usecase + +import com.sampoom.android.feature.order.domain.repository.OrderRepository +import javax.inject.Inject + +class ReceiveOrderUseCase @Inject constructor( + private val repository: OrderRepository +) { + suspend operator fun invoke(orderId: Long) = repository.receiveOrder(orderId) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailContent.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailContent.kt new file mode 100644 index 0000000..f30e900 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailContent.kt @@ -0,0 +1,188 @@ +package com.sampoom.android.feature.order.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sampoom.android.R +import com.sampoom.android.core.ui.component.StatusChip +import com.sampoom.android.core.ui.theme.backgroundCardColor +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.core.ui.theme.textSecondaryColor +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.model.OrderPart +import kotlin.collections.forEach + +@Composable +fun OrderDetailContent( + order: List, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + order.forEach { order -> + item { + OrderInfoCard(order = order) + } + item { + Text( + text = stringResource(R.string.order_detail_order_items_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = textColor() + ) + } + order.items.forEach { category -> + category.groups.forEach { group -> + item { + OrderSection( + categoryName = category.categoryName, + groupName = group.groupName, + parts = group.parts + ) + } + } + } + } + } +} + + +@Composable +private fun OrderInfoCard(order: Order) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundCardColor()) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + OrderInfoRow( + label = stringResource(R.string.order_detail_order_number), + value = order.orderNumber ?: stringResource(R.string.common_slash) + ) + OrderInfoRow( + label = stringResource(R.string.order_detail_order_date), + value = order.createdAt ?: stringResource(R.string.common_slash) + ) + OrderInfoRow( + label = stringResource(R.string.order_detail_order_agency), + value = order.agencyName ?: stringResource(R.string.common_slash) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.order_detail_order_status), + style = MaterialTheme.typography.bodyMedium, + color = textSecondaryColor() + ) + + StatusChip(status = order.status) + } + } + } +} + +@Composable +private fun OrderInfoRow( + label: String, + value: String +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = textSecondaryColor() + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = textColor() + ) + } +} + +@Composable +private fun OrderSection( + categoryName: String, + groupName: String, + parts: List +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "$categoryName > $groupName", + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + + parts.forEach { part -> + OrderPartItem(part = part) + } + } +} + +@Composable +private fun OrderPartItem( + part: OrderPart +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundCardColor()) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1F) + ) { + Text( + text = part.name, + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + Text( + text = part.code, + style = MaterialTheme.typography.bodySmall, + color = textSecondaryColor() + ) + } + + Text( + text = part.quantity.toString(), + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailScreen.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailScreen.kt new file mode 100644 index 0000000..d4bbfcc --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailScreen.kt @@ -0,0 +1,232 @@ +package com.sampoom.android.feature.order.ui + +import android.R.attr.order +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sampoom.android.R +import com.sampoom.android.core.ui.component.ButtonVariant +import com.sampoom.android.core.ui.component.CommonButton +import com.sampoom.android.core.ui.component.EmptyContent +import com.sampoom.android.core.ui.component.ErrorContent +import com.sampoom.android.feature.order.domain.model.OrderStatus +import com.sampoom.android.feature.outbound.ui.OutboundListUiEvent + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrderDetailScreen( + onNavigateBack: () -> Unit = {}, + viewModel: OrderDetailViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() + var showCancelOrderDialog by remember { mutableStateOf(false) } + var showReceiveOrderDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + + // 성공 시 Toast 표시 후 다이얼로그 닫기 + LaunchedEffect(uiState.isProcessingCancelSuccess) { + if (uiState.isProcessingCancelSuccess) { + Toast.makeText(context, context.getString(R.string.order_detail_toast_order_cancel), Toast.LENGTH_SHORT).show() + viewModel.clearSuccess() + viewModel.onEvent(OrderDetailUiEvent.LoadOrder) + } + } + + // 성공 시 Toast 표시 후 다이얼로그 닫기 + LaunchedEffect(uiState.isProcessingReceiveSuccess) { + if (uiState.isProcessingReceiveSuccess) { + Toast.makeText(context, context.getString(R.string.order_detail_toast_order_receive), Toast.LENGTH_SHORT).show() + viewModel.clearSuccess() + viewModel.onEvent(OrderDetailUiEvent.LoadOrder) + } + } + + // 실패 시 Toast 표시 + LaunchedEffect(uiState.isProcessingError) { + uiState.isProcessingError?.let { error -> + Toast.makeText(context, error, Toast.LENGTH_LONG).show() + viewModel.onEvent(OrderDetailUiEvent.ClearError) + } + } + + PullToRefreshBox( + isRefreshing = uiState.orderDetailLoading, + onRefresh = { viewModel.onEvent(OrderDetailUiEvent.LoadOrder) }, + state = pullRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = uiState.orderDetailLoading, + containerColor = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.onPrimaryContainer, + state = pullRefreshState + ) + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.order_detail_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.nav_back) + ) + } + } + ) + }, + bottomBar = { + val orderStatus = uiState.orderDetail.firstOrNull()?.status + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + CommonButton( + modifier = Modifier.weight(1f), + variant = ButtonVariant.Error, + enabled = orderStatus != null && + !uiState.isProcessing && + orderStatus == OrderStatus.PENDING, + onClick = { showCancelOrderDialog = true } + ) { + Text(stringResource(R.string.order_detail_order_cancel)) + } + Spacer(Modifier.width(16.dp)) + CommonButton( + modifier = Modifier.weight(1f), + enabled = orderStatus != null && + !uiState.isProcessing && + orderStatus == OrderStatus.PENDING, + onClick = { showReceiveOrderDialog = true } + ) { + Text(stringResource(R.string.order_detail_order_receive)) + } + } + } + ) { innerPadding -> + when { + uiState.orderDetailLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + uiState.orderDetailError != null -> { + ErrorContent( + onRetry = { viewModel.onEvent(OrderDetailUiEvent.RetryOrder) }, + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + ) + } + + uiState.orderDetail.isEmpty() -> { + EmptyContent( + message = stringResource(R.string.order_empty_list), + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + ) + } + + else -> { + OrderDetailContent( + order = uiState.orderDetail, + modifier = Modifier.padding(innerPadding) + ) + } + } + } + } + + if (showCancelOrderDialog) { + AlertDialog( + onDismissRequest = { showCancelOrderDialog = false }, + text = { Text(stringResource(R.string.order_detail_dialog_order_cancel)) }, + confirmButton = { + TextButton( + onClick = { + showCancelOrderDialog = false + viewModel.onEvent(OrderDetailUiEvent.CancelOrder) + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showCancelOrderDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } + + if (showReceiveOrderDialog) { + AlertDialog( + onDismissRequest = { showReceiveOrderDialog = false }, + text = { Text(stringResource(R.string.order_detail_dialog_order_receive)) }, + confirmButton = { + TextButton( + onClick = { + showReceiveOrderDialog = false + viewModel.onEvent(OrderDetailUiEvent.ReceiveOrder) + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showReceiveOrderDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailUiEvent.kt new file mode 100644 index 0000000..90e3fb5 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailUiEvent.kt @@ -0,0 +1,9 @@ +package com.sampoom.android.feature.order.ui + +sealed interface OrderDetailUiEvent { + object LoadOrder : OrderDetailUiEvent + object RetryOrder : OrderDetailUiEvent + object ReceiveOrder : OrderDetailUiEvent + object CancelOrder : OrderDetailUiEvent + object ClearError : OrderDetailUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailUiState.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailUiState.kt new file mode 100644 index 0000000..fd4a4a7 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailUiState.kt @@ -0,0 +1,13 @@ +package com.sampoom.android.feature.order.ui + +import com.sampoom.android.feature.order.domain.model.Order + +data class OrderDetailUiState( + val orderDetail: List = emptyList(), + val orderDetailLoading: Boolean = false, + val orderDetailError: String? = null, + val isProcessing: Boolean = false, + val isProcessingCancelSuccess: Boolean = false, + val isProcessingReceiveSuccess: Boolean = false, + val isProcessingError: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailViewModel.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailViewModel.kt new file mode 100644 index 0000000..a0bd0af --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailViewModel.kt @@ -0,0 +1,154 @@ +package com.sampoom.android.feature.order.ui + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.core.network.serverMessageOrNull +import com.sampoom.android.feature.order.domain.usecase.CancelOrderUseCase +import com.sampoom.android.feature.order.domain.usecase.GetOrderDetailUseCase +import com.sampoom.android.feature.order.domain.usecase.ReceiveOrderUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OrderDetailViewModel @Inject constructor( + private val getOrderDetailUseCase: GetOrderDetailUseCase, + private val cancelOrderUseCase: CancelOrderUseCase, + private val receiveOrderUseCase: ReceiveOrderUseCase, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private companion object { + private const val TAG = "OrderDetailViewModel" + } + + private val _uiState = MutableStateFlow(OrderDetailUiState()) + val uiState: StateFlow = _uiState + + // Navigation 인자 로드 + private val agencyId: Long = savedStateHandle.get("agencyId") ?: 0L + private val navOrderId: Long = savedStateHandle.get("orderId") ?: 0L + + private var apiOrderId: Long? = null + + fun setOrderIdFromApi(orderId: Long) { + apiOrderId = orderId + } + + private fun getOrderId(): Long { + return apiOrderId ?: navOrderId + } + + private var errorLabel: String = "" + + fun bindLabel(error: String) { + errorLabel = error + } + + init { + if (getOrderId() > 0L) loadOrderDetail(getOrderId()) + else _uiState.update { it.copy(orderDetailError = errorLabel) } + } + + fun onEvent(event: OrderDetailUiEvent) { + when (event) { + is OrderDetailUiEvent.LoadOrder -> loadOrderDetail(getOrderId()) + is OrderDetailUiEvent.RetryOrder -> loadOrderDetail(getOrderId()) + is OrderDetailUiEvent.CancelOrder -> cancelOrder(getOrderId()) + is OrderDetailUiEvent.ReceiveOrder -> receiveOrder(getOrderId()) + is OrderDetailUiEvent.ClearError -> _uiState.update { it.copy(isProcessingError = null) } + } + } + + private fun loadOrderDetail(orderId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(orderDetailLoading = true, orderDetailError = null) } + + try { + val orderList = getOrderDetailUseCase(orderId) + _uiState.update { + it.copy( + orderDetail = orderList.items, + orderDetailLoading = false, + orderDetailError = null + ) + } + } catch (ce : CancellationException) { + throw ce + } catch (throwable : Throwable) { + val backendMessage = throwable.serverMessageOrNull() + _uiState.update { + it.copy( + orderDetailLoading = false, + orderDetailError = backendMessage ?: (throwable.message ?: errorLabel) + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun cancelOrder(orderId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isProcessing = true, isProcessingError = null) } + + cancelOrderUseCase(orderId) + .onSuccess { + _uiState.update { + it.copy( + isProcessing = false, + isProcessingCancelSuccess = true, + isProcessingError = null + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + _uiState.update { + it.copy( + isProcessing = false, + isProcessingError = backendMessage ?: (throwable.message ?: errorLabel) + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun receiveOrder(orderId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isProcessing = true, isProcessingError = null) } + + receiveOrderUseCase(orderId) + .onSuccess { + _uiState.update { + it.copy( + isProcessing = false, + isProcessingReceiveSuccess = true, + isProcessingError = null + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + _uiState.update { + it.copy( + isProcessing = false, + isProcessingError = backendMessage ?: (throwable.message ?: errorLabel) + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + fun clearSuccess() { + _uiState.update { it.copy(isProcessingCancelSuccess = false, isProcessingReceiveSuccess = false) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListScreen.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListScreen.kt new file mode 100644 index 0000000..6125a00 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListScreen.kt @@ -0,0 +1,204 @@ +package com.sampoom.android.feature.order.ui + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sampoom.android.R +import com.sampoom.android.core.ui.component.EmptyContent +import com.sampoom.android.core.ui.component.ErrorContent +import com.sampoom.android.core.ui.component.StatusChip +import com.sampoom.android.core.ui.theme.backgroundCardColor +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.core.ui.theme.textSecondaryColor +import com.sampoom.android.core.util.buildOrderTitle +import com.sampoom.android.core.util.formatDate +import com.sampoom.android.feature.order.domain.model.Order + +@RequiresApi(Build.VERSION_CODES.O) +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun OrderListScreen( + paddingValues: PaddingValues, + onNavigateOrderDetail: (Order) -> Unit, + viewModel: OrderListViewModel = hiltViewModel() +) { + val errorLabel = stringResource(R.string.common_error) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() + val listState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + + LaunchedEffect(errorLabel) { + viewModel.bindLabel(errorLabel) + } + + PullToRefreshBox( + isRefreshing = uiState.orderLoading, + onRefresh = { viewModel.onEvent(OrderListUiEvent.LoadOrderList) }, + state = pullRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = uiState.orderLoading, + containerColor = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.onPrimaryContainer, + state = pullRefreshState + ) + } + ) { + Column(Modifier.fillMaxSize().padding(paddingValues)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .padding(vertical = 16.dp), + text = stringResource(R.string.order_title), + style = MaterialTheme.typography.titleLarge, + color = textColor() + ) + } + + when { + uiState.orderLoading -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + uiState.orderError != null -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorContent( + onRetry = { viewModel.onEvent(OrderListUiEvent.RetryOrderList) }, + modifier = Modifier.height(200.dp) + ) + } + } + + uiState.orderList.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + EmptyContent( + message = stringResource(R.string.order_empty_list), + modifier = Modifier.height(200.dp) + ) + } + } + + else -> { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(uiState.orderList) { order -> + OrderItem( + order = order, + onClick = { onNavigateOrderDetail(order) } + ) + } + item { Spacer(Modifier.height(100.dp)) } + } + } + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun OrderItem( + order: Order, + onClick: () -> Unit +) { + Card( + onClick = { onClick() }, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundCardColor()) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = buildOrderTitle(order), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) + Spacer(Modifier.height(4.dp)) + Text( + text = order.agencyName ?: stringResource(R.string.common_slash), + style = MaterialTheme.typography.labelMedium, + color = textSecondaryColor() + ) + } + + Spacer(Modifier.width(12.dp)) + + Column(horizontalAlignment = Alignment.End) { + Text( + text = order.createdAt?.let { formatDate(it) } ?: stringResource(R.string.common_slash), + style = MaterialTheme.typography.labelMedium, + color = textSecondaryColor() + ) + Spacer(Modifier.height(6.dp)) + StatusChip(status = order.status) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListUiEvent.kt new file mode 100644 index 0000000..dfb3e8c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListUiEvent.kt @@ -0,0 +1,6 @@ +package com.sampoom.android.feature.order.ui + +sealed interface OrderListUiEvent { + object LoadOrderList : OrderListUiEvent + object RetryOrderList : OrderListUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListUiState.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListUiState.kt new file mode 100644 index 0000000..8e7e25b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListUiState.kt @@ -0,0 +1,9 @@ +package com.sampoom.android.feature.order.ui + +import com.sampoom.android.feature.order.domain.model.Order + +data class OrderListUiState( + val orderList: List = emptyList(), + val orderLoading: Boolean = false, + val orderError: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListViewModel.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListViewModel.kt new file mode 100644 index 0000000..0a4fab1 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListViewModel.kt @@ -0,0 +1,77 @@ +package com.sampoom.android.feature.order.ui + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.core.network.serverMessageOrNull +import com.sampoom.android.feature.order.domain.usecase.GetOrderUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class OrderListViewModel @Inject constructor( + private val getOrderListUseCase: GetOrderUseCase +) : ViewModel() { + + private companion object { + private const val TAG = "OrderListViewModel" + } + + private val _uiState = MutableStateFlow(OrderListUiState()) + val uiState: StateFlow = _uiState + + private var errorLabel: String = "" + private var loadJob: Job? = null + + fun bindLabel(error: String) { + errorLabel = error + } + + init { + loadOrderList() + } + + fun onEvent(event: OrderListUiEvent) { + when (event) { + is OrderListUiEvent.LoadOrderList -> loadOrderList() + is OrderListUiEvent.RetryOrderList -> loadOrderList() + } + } + + private fun loadOrderList() { + if (loadJob?.isActive == true) return + loadJob = viewModelScope.launch { + _uiState.update { it.copy(orderLoading = true, orderError = null) } + + try { + val orderList = withContext(Dispatchers.IO) { getOrderListUseCase() } + _uiState.update { + it.copy( + orderList = orderList.items, + orderLoading = false, + orderError = null + ) + } + } catch (ce: CancellationException) { + throw ce + } catch (throwable: Throwable) { + val backendMessage = throwable.serverMessageOrNull() + _uiState.update { + it.copy( + orderLoading = false, + orderError = backendMessage ?: (throwable.message ?: errorLabel ) + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/ui/OrderResultBottomSheet.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderResultBottomSheet.kt new file mode 100644 index 0000000..e859080 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderResultBottomSheet.kt @@ -0,0 +1,176 @@ +package com.sampoom.android.feature.order.ui + +import android.R.attr.onClick +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sampoom.android.R +import com.sampoom.android.core.ui.component.ButtonVariant +import com.sampoom.android.core.ui.component.CommonButton +import com.sampoom.android.core.ui.theme.SuccessGreen +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.model.OrderStatus + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrderResultBottomSheet( + order: List, + onDismiss: () -> Unit, + viewModel: OrderDetailViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + var showCancelOrderDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + + LaunchedEffect(order.firstOrNull()?.orderId) { + if (order.isNotEmpty()) viewModel.setOrderIdFromApi(order.first().orderId) + } + + // 성공 시 Toast 표시 후 다이얼로그 닫기 + LaunchedEffect(uiState.isProcessingCancelSuccess) { + if (uiState.isProcessingCancelSuccess) { + Toast.makeText(context, context.getString(R.string.order_detail_toast_order_cancel), Toast.LENGTH_SHORT).show() + viewModel.clearSuccess() + viewModel.onEvent(OrderDetailUiEvent.LoadOrder) + } + } + + // 실패 시 Toast 표시 + LaunchedEffect(uiState.isProcessingError) { + uiState.isProcessingError?.let { error -> + Toast.makeText(context, error, Toast.LENGTH_LONG).show() + viewModel.onEvent(OrderDetailUiEvent.ClearError) + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxHeight(0.9f) + ) { + // 주문 완료 헤더 + OrderCompleteHeader() + + Spacer(modifier = Modifier.height(16.dp)) + + // OrderDetailContent 재사용 + OrderDetailContent( + order = order, + modifier = Modifier.weight(1f) + ) + + // 하단 고정 버튼들 + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + val orderStatus = order.firstOrNull()?.status + CommonButton( + modifier = Modifier.weight(1f), + variant = ButtonVariant.Error, + enabled = orderStatus != null && + !uiState.isProcessing && + orderStatus == OrderStatus.PENDING, + onClick = { showCancelOrderDialog = true } + ) { + Text(stringResource(R.string.order_detail_order_cancel)) + } + } + + // 하단 여백 + Spacer(modifier = Modifier.height(16.dp)) + } + } + + if (showCancelOrderDialog) { + AlertDialog( + onDismissRequest = { showCancelOrderDialog = false }, + text = { Text(stringResource(R.string.order_detail_dialog_order_cancel)) }, + confirmButton = { + TextButton( + onClick = { + showCancelOrderDialog = false + viewModel.onEvent(OrderDetailUiEvent.CancelOrder) + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showCancelOrderDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } +} + +@Composable +private fun OrderCompleteHeader() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check_circle), + contentDescription = stringResource(R.string.common_confirm), + tint = SuccessGreen, + modifier = Modifier.size(20.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(R.string.cart_toast_order_text), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = textColor() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/api/OutboundApi.kt b/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/api/OutboundApi.kt index 02c13bf..d7ee115 100644 --- a/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/api/OutboundApi.kt +++ b/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/api/OutboundApi.kt @@ -12,6 +12,7 @@ import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path +// TODO: AgencyId 동적 주입 interface OutboundApi { // 출고 목록 조회 @GET("agency/1/outbound") diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/data/repository/OutboundRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/outbound/data/repository/OutboundRepositoryImpl.kt index 1139cf5..71f3245 100644 --- a/app/src/main/java/com/sampoom/android/feature/outbound/data/repository/OutboundRepositoryImpl.kt +++ b/app/src/main/java/com/sampoom/android/feature/outbound/data/repository/OutboundRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.sampoom.android.feature.outbound.data.remote.dto.UpdateOutboundReques import com.sampoom.android.feature.outbound.domain.model.OutboundList import com.sampoom.android.feature.outbound.domain.repository.OutboundRepository import jakarta.inject.Inject +import kotlin.coroutines.cancellation.CancellationException class OutboundRepositoryImpl @Inject constructor( private val api: OutboundApi @@ -18,9 +19,14 @@ class OutboundRepositoryImpl @Inject constructor( } override suspend fun processOutbound(): Result { - val dto = api.processOutbound() - return runCatching { + return try { + val dto = api.processOutbound() if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce : CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) } } @@ -28,23 +34,38 @@ class OutboundRepositoryImpl @Inject constructor( partId: Long, quantity: Long ): Result { - val dto = api.addOutbound(AddOutboundRequestDto(partId, quantity)) - return runCatching { + return try { + val dto = api.addOutbound(AddOutboundRequestDto(partId, quantity)) if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce : CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) } } override suspend fun deleteOutbound(outboundId: Long): Result { - val dto = api.deleteOutbound(outboundId) - return runCatching { + return try { + val dto = api.deleteOutbound(outboundId) if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce : CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) } } override suspend fun deleteAllOutbound(): Result { - val dto = api.deleteAllOutbound() - return runCatching { + return try { + val dto = api.deleteAllOutbound() if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce : CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) } } @@ -52,9 +73,14 @@ class OutboundRepositoryImpl @Inject constructor( outboundId: Long, quantity: Long ): Result { - val dto = api.updateOutbound(outboundId, UpdateOutboundRequestDto(quantity)) - return runCatching { + return try { + val dto = api.updateOutbound(outboundId, UpdateOutboundRequestDto(quantity)) if (!dto.success) throw Exception(dto.message) + Result.success(Unit) + } catch (ce : CancellationException) { + throw ce + } catch (t : Throwable) { + Result.failure(t) } } } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListScreen.kt b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListScreen.kt index 4323ec8..21c1664 100644 --- a/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListScreen.kt +++ b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListScreen.kt @@ -4,6 +4,7 @@ import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -21,6 +22,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -45,15 +49,18 @@ import com.sampoom.android.core.ui.theme.FailRed import com.sampoom.android.core.ui.theme.backgroundCardColor import com.sampoom.android.core.ui.theme.textColor import com.sampoom.android.core.ui.theme.textSecondaryColor +import com.sampoom.android.feature.order.ui.OrderListUiEvent import com.sampoom.android.feature.outbound.domain.model.OutboundPart @OptIn(ExperimentalMaterial3Api::class) @Composable fun OutboundListScreen( + paddingValues: PaddingValues, viewModel: OutboundListViewModel = hiltViewModel() ) { val errorLabel = stringResource(R.string.common_error) val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() var showEmptyOutboundDialog by remember { mutableStateOf(false) } var showConfirmDialog by remember { mutableStateOf(false) } val context = LocalContext.current @@ -70,116 +77,131 @@ fun OutboundListScreen( LaunchedEffect(uiState.isOrderSuccess) { if (uiState.isOrderSuccess) { Toast.makeText(context, context.getString(R.string.outbound_toast_order_text), Toast.LENGTH_SHORT).show() + viewModel.clearSuccess() } - viewModel.clearSuccess() } - - Column(Modifier.fillMaxSize()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier - .padding(vertical = 16.dp), - text = stringResource(R.string.outbound_title), - style = MaterialTheme.typography.titleLarge, - color = textColor() + PullToRefreshBox( + isRefreshing = uiState.outboundLoading, + onRefresh = { viewModel.onEvent(OutboundListUiEvent.LoadOutboundList) }, + state = pullRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = uiState.outboundLoading, + containerColor = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.onPrimaryContainer, + state = pullRefreshState ) - - when { - uiState.outboundLoading -> {} - uiState.outboundError != null -> {} - uiState.outboundList.isEmpty() -> {} - else -> { - TextButton( - onClick = { showEmptyOutboundDialog = true } - ) { - Text( - text = stringResource(R.string.outbound_empty_list), - style = MaterialTheme.typography.titleMedium, - color = FailRed - ) - } - } - } } - - when { - uiState.outboundLoading -> { - Box( + ) { + Column(Modifier.fillMaxSize().padding(paddingValues)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() + .padding(vertical = 16.dp), + text = stringResource(R.string.outbound_title), + style = MaterialTheme.typography.titleLarge, + color = textColor() + ) + + when { + uiState.outboundLoading -> {} + uiState.outboundError != null -> {} + uiState.outboundList.isEmpty() -> {} + else -> { + TextButton( + onClick = { showEmptyOutboundDialog = true } + ) { + Text( + text = stringResource(R.string.outbound_empty_list), + style = MaterialTheme.typography.titleMedium, + color = FailRed + ) + } + } } } - uiState.outboundError != null -> { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - ErrorContent( - onRetry = { viewModel.onEvent(OutboundListUiEvent.RetryOutboundList) }, - modifier = Modifier.height(200.dp) - ) + when { + uiState.outboundLoading -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } } - } - uiState.outboundList.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - EmptyContent( - message = stringResource(R.string.outbound_empty_outbound), - modifier = Modifier.height(200.dp) - ) + uiState.outboundError != null -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorContent( + onRetry = { viewModel.onEvent(OutboundListUiEvent.RetryOutboundList) }, + modifier = Modifier.height(200.dp) + ) + } } - } - else -> { - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( + uiState.outboundList.isEmpty() -> { + Box( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .fillMaxSize(), + contentAlignment = Alignment.Center ) { - uiState.outboundList.forEach { category -> - category.groups.forEach { group -> - item { - OutboundSection( - categoryName = category.categoryName, - groupName = group.groupName, - parts = group.parts, - isUpdating = uiState.isUpdating, - isDeleting = uiState.isDeleting, - onEvent = { viewModel.onEvent(it) } - ) + EmptyContent( + message = stringResource(R.string.outbound_empty_outbound), + modifier = Modifier.height(200.dp) + ) + } + } + + else -> { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + uiState.outboundList.forEach { category -> + category.groups.forEach { group -> + item { + OutboundSection( + categoryName = category.categoryName, + groupName = group.groupName, + parts = group.parts, + isUpdating = uiState.isUpdating, + isDeleting = uiState.isDeleting, + onEvent = { viewModel.onEvent(it) } + ) + } } } + item { Spacer(Modifier.height(100.dp)) } } - item { Spacer(Modifier.height(100.dp)) } - } - CommonButton( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomEnd) - .padding(16.dp) - .padding(end = 72.dp), - variant = ButtonVariant.Error, - size = ButtonSize.Large, - onClick = { showConfirmDialog = true } - ) { Text(stringResource(R.string.outbound_order_parts)) } + CommonButton( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomEnd) + .padding(16.dp) + .padding(end = 72.dp), + variant = ButtonVariant.Error, + size = ButtonSize.Large, + onClick = { showConfirmDialog = true } + ) { Text(stringResource(R.string.outbound_order_parts)) } + } } } } diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListViewModel.kt b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListViewModel.kt index 4fa3c09..8a49c79 100644 --- a/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListViewModel.kt +++ b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListViewModel.kt @@ -10,6 +10,7 @@ import com.sampoom.android.feature.outbound.domain.usecase.GetOutboundUseCase import com.sampoom.android.feature.outbound.domain.usecase.ProcessOutboundUseCase import com.sampoom.android.feature.outbound.domain.usecase.UpdateOutboundQuantityUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -59,26 +60,26 @@ class OutboundListViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(outboundLoading = true, outboundError = null) } - runCatching { getOutboundUseCase() } - .onSuccess { outboundList -> - _uiState.update { - it.copy( - outboundList = outboundList.items, - outboundLoading = false, - outboundError = null - ) - } + try { + val outboundList = getOutboundUseCase() + _uiState.update { + it.copy( + outboundList = outboundList.items, + outboundLoading = false, + outboundError = null + ) } - .onFailure { throwable -> - val backendMessage = throwable.serverMessageOrNull() - _uiState.update { - it.copy( - outboundLoading = false, - outboundError = backendMessage ?: (throwable.message ?: errorLabel) - ) - } - + } catch (ce: CancellationException) { + throw ce + } catch (throwable: Throwable) { + val backendMessage = throwable.serverMessageOrNull() + _uiState.update { + it.copy( + outboundLoading = false, + outboundError = backendMessage ?: (throwable.message ?: errorLabel) + ) } + } Log.d(TAG, "submit: ${_uiState.value}") } } diff --git a/app/src/main/java/com/sampoom/android/feature/part/data/remote/api/PartApi.kt b/app/src/main/java/com/sampoom/android/feature/part/data/remote/api/PartApi.kt index 06f1004..eaea882 100644 --- a/app/src/main/java/com/sampoom/android/feature/part/data/remote/api/PartApi.kt +++ b/app/src/main/java/com/sampoom/android/feature/part/data/remote/api/PartApi.kt @@ -7,6 +7,7 @@ import com.sampoom.android.feature.part.data.remote.dto.PartDto import retrofit2.http.GET import retrofit2.http.Path +// TODO: AgencyId 동적 주입 interface PartApi { @GET("agency/category") suspend fun getCategoryList(): ApiResponse> diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailBottomSheet.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailBottomSheet.kt index 24419cd..d9a2701 100644 --- a/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailBottomSheet.kt +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailBottomSheet.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -42,7 +41,6 @@ import com.sampoom.android.core.ui.component.CommonButton import com.sampoom.android.core.ui.theme.textColor import com.sampoom.android.core.ui.theme.textSecondaryColor import com.sampoom.android.feature.part.domain.model.Part -import kotlinx.coroutines.delay @Composable fun PartDetailBottomSheet( @@ -72,16 +70,16 @@ fun PartDetailBottomSheet( LaunchedEffect(uiState.isOutboundSuccess) { if (uiState.isOutboundSuccess) { Toast.makeText(context, context.getString(R.string.outbound_toast_success), Toast.LENGTH_SHORT).show() + viewModel.clearSuccess() } - viewModel.clearSuccess() } // 성공 시 Toast 표시 후 다이얼로그 닫기 LaunchedEffect(uiState.isCartSuccess) { if (uiState.isCartSuccess) { Toast.makeText(context, context.getString(R.string.cart_toast_success), Toast.LENGTH_SHORT).show() + viewModel.clearSuccess() } - viewModel.clearSuccess() } // 실패 시 Toast 표시 diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartListScreen.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListScreen.kt index 17f7c76..b32c2cb 100644 --- a/app/src/main/java/com/sampoom/android/feature/part/ui/PartListScreen.kt +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListScreen.kt @@ -22,6 +22,9 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -44,8 +47,8 @@ import com.sampoom.android.core.ui.component.ErrorContent import com.sampoom.android.core.ui.theme.backgroundCardColor import com.sampoom.android.core.ui.theme.textColor import com.sampoom.android.core.ui.theme.textSecondaryColor +import com.sampoom.android.feature.outbound.ui.OutboundListUiEvent import com.sampoom.android.feature.part.domain.model.Part -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -60,82 +63,98 @@ fun PartListScreen( } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() // ModalBottomSheet 상태 관리 val sheetState = rememberModalBottomSheetState() - val scope = rememberCoroutineScope() var showBottomSheet by remember { mutableStateOf(false) } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.part_title)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = stringResource(R.string.nav_back) - ) - } - } + PullToRefreshBox( + isRefreshing = uiState.partListLoading, + onRefresh = { viewModel.onEvent(PartListUiEvent.LoadPartList) }, + state = pullRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = uiState.partListLoading, + containerColor = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.onPrimaryContainer, + state = pullRefreshState ) } - ) { innerPadding -> - when { - uiState.partListLoading -> { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.part_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.nav_back) + ) + } + } + ) } - - uiState.partListError != null -> { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - ErrorContent( - onRetry = { viewModel.onEvent(PartListUiEvent.RetryPartList) }, - modifier = Modifier.height(200.dp) - ) + ) { innerPadding -> + when { + uiState.partListLoading -> { + Box( + modifier = Modifier + .fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } } - } - uiState.partList.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - EmptyContent( - message = stringResource(R.string.part_empty_part), - modifier = Modifier.height(200.dp) - ) + uiState.partListError != null -> { + Box( + modifier = Modifier + .fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center + ) { + ErrorContent( + onRetry = { viewModel.onEvent(PartListUiEvent.RetryPartList) }, + modifier = Modifier.height(200.dp) + ) + } } - } - else -> { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(uiState.partList) { part -> - PartListItemCard( - part = part, - onClick = { - viewModel.onEvent(PartListUiEvent.ShowBottomSheet(part)) - showBottomSheet = true - } + uiState.partList.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center + ) { + EmptyContent( + message = stringResource(R.string.part_empty_part), + modifier = Modifier.height(200.dp) ) } } + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(uiState.partList) { part -> + PartListItemCard( + part = part, + onClick = { + viewModel.onEvent(PartListUiEvent.ShowBottomSheet(part)) + showBottomSheet = true + } + ) + } + } + } } } } diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartListViewModel.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListViewModel.kt index f582f31..80897f3 100644 --- a/app/src/main/java/com/sampoom/android/feature/part/ui/PartListViewModel.kt +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.sampoom.android.core.network.serverMessageOrNull import com.sampoom.android.feature.part.domain.usecase.GetPartUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -54,25 +55,26 @@ class PartListViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(partListLoading = true, partListError = null) } - runCatching { getPartListUseCase(groupId) } - .onSuccess { partList -> - _uiState.update { - it.copy( - partList = partList.items, - partListLoading = false, - partListError = null - ) - } + try { + val partList = getPartListUseCase(groupId) + _uiState.update { + it.copy( + partList = partList.items, + partListLoading = false, + partListError = null + ) } - .onFailure { throwable -> - val backendMessage = throwable.serverMessageOrNull() - _uiState.update { - it.copy( - partListLoading = false, - partListError = backendMessage ?: (throwable.message ?: errorLabel) - ) - } + } catch (ce: CancellationException) { + throw ce + } catch (throwable: Throwable) { + val backendMessage = throwable.serverMessageOrNull() + _uiState.update { + it.copy( + partListLoading = false, + partListError = backendMessage ?: (throwable.message ?: errorLabel) + ) } + } Log.d(TAG, "submit: ${_uiState.value}") } } diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartScreen.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartScreen.kt index 97ae617..3585da3 100644 --- a/app/src/main/java/com/sampoom/android/feature/part/ui/PartScreen.kt +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartScreen.kt @@ -116,14 +116,14 @@ fun PartScreen( uiState.categoryError != null -> { ErrorContent( onRetry = { viewModel.onEvent(PartUiEvent.RetryCategories) }, - modifier = Modifier.height(200.dp) + modifier = Modifier.height(200.dp).fillMaxWidth() ) } uiState.categoryList.isEmpty() -> { EmptyContent( message = stringResource(R.string.part_empty_category), - modifier = Modifier.height(200.dp) + modifier = Modifier.height(200.dp).fillMaxWidth() ) } diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartViewModel.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartViewModel.kt index 2a87763..bb8e0c8 100644 --- a/app/src/main/java/com/sampoom/android/feature/part/ui/PartViewModel.kt +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartViewModel.kt @@ -8,6 +8,7 @@ import com.sampoom.android.feature.part.domain.model.Category import com.sampoom.android.feature.part.domain.usecase.GetCategoryUseCase import com.sampoom.android.feature.part.domain.usecase.GetGroupUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -52,25 +53,26 @@ class PartViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(categoryLoading = true, categoryError = null) } - runCatching { getCategoryUseCase() } - .onSuccess { categoryList -> - _uiState.update { - it.copy( - categoryList = categoryList.items, - categoryLoading = false, - categoryError = null - ) - } + try { + val categoryList = getCategoryUseCase() + _uiState.update { + it.copy( + categoryList = categoryList.items, + categoryLoading = false, + categoryError = null + ) } - .onFailure { throwable -> - val backendMessage = throwable.serverMessageOrNull() - _uiState.update { - it.copy( - categoryLoading = false, - categoryError = backendMessage ?: (throwable.message ?: errorLabel) - ) - } + } catch (ce: CancellationException) { + throw ce + } catch (throwable: Throwable) { + val backendMessage = throwable.serverMessageOrNull() + _uiState.update { + it.copy( + categoryLoading = false, + categoryError = backendMessage ?: (throwable.message ?: errorLabel) + ) } + } Log.d(TAG, "loadCategory: ${_uiState.value}") } } @@ -88,28 +90,29 @@ class PartViewModel @Inject constructor( groupLoadJob = viewModelScope.launch { _uiState.update { it.copy(groupLoading = true, groupError = null) } - runCatching { getGroupUseCase(categoryId) } - .onSuccess { groupList -> - // 최신 선택과 불일치하면 무시 - if (_uiState.value.selectedCategory?.id != categoryId) return@onSuccess - _uiState.update { - it.copy( - groupList = groupList.items, - groupLoading = false, - groupError = null - ) - } + try { + val groupList = getGroupUseCase(categoryId) + // 최신 선택과 불일치하면 무시 + if (_uiState.value.selectedCategory?.id != categoryId) return@launch + _uiState.update { + it.copy( + groupList = groupList.items, + groupLoading = false, + groupError = null + ) } - .onFailure { throwable -> - val backendMessage = throwable.serverMessageOrNull() - if (_uiState.value.selectedCategory?.id != categoryId) return@onFailure - _uiState.update { - it.copy( - groupLoading = false, - groupError = backendMessage ?: (throwable.message ?: errorLabel) - ) - } + } catch (ce: CancellationException) { + throw ce + } catch (throwable: Throwable) { + val backendMessage = throwable.serverMessageOrNull() + if (_uiState.value.selectedCategory?.id != categoryId) return@launch + _uiState.update { + it.copy( + groupLoading = false, + groupError = backendMessage ?: (throwable.message ?: errorLabel) + ) } + } Log.d(TAG, "loadGroup: ${_uiState.value}") } } diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..7f747c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7faede2..1956094 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,11 +73,32 @@ 장바구니 목록이 없습니다. 장바구니 목록에 추가하시겠습니까? 장바구니 목록에 추가되었습니다 - 장바구니에 추가하시겠습니까? + 장바구니 목록의 상품을 주문하시겠습니까? 장바구니를 비우시겠습니까? - 추가되었습니다 + 주문이 완료되었습니다 부품 주문 + + 주문관리 + 주문관리 목록이 없습니다. + 주문정보 + 주문번호 + 주문일자 + 대리점 + 주문상태 + 주문상품 + 주문취소 + 주문 취소처리하시겠습니까? + 주문 취소처리되었습니다 + 입고처리 + 입고 처리하시겠습니까? + 입고 처리되었습니다 + + + 승인대기 + 입고완료 + 주문취소 + 오류가 발생했습니다 다시 시도 @@ -87,6 +108,7 @@ 삭제 상세 보기 EA + - 이메일을 입력해주세요