diff --git a/.github/workflows/assign-issue-creator.yml b/.github/workflows/assign-issue-creator.yml new file mode 100644 index 0000000..6dde730 --- /dev/null +++ b/.github/workflows/assign-issue-creator.yml @@ -0,0 +1,14 @@ +# 각 레포지토리의 .github/workflows/assign-issue-creator.yml + +name: Assign issue creator + +on: + issues: + types: [opened] + +jobs: + call-reusable-workflow: + # @main 은 .github 레포지토리의 main 브랜치를 사용한다는 의미입니다. 버전을 위해 @v1과 같이 태그를 사용하는 것을 권장합니다. + uses: 33-Auto/.github/.github/workflows/reusable-assign-issue-creator.yml@main + # 이 워크플로우는 secrets를 전달할 필요가 없지만, 필요 시 아래와 같이 전달합니다. + # secrets: inherit \ No newline at end of file diff --git a/.github/workflows/close-issues-on-dev-merge.yml b/.github/workflows/close-issues-on-dev-merge.yml new file mode 100644 index 0000000..325a846 --- /dev/null +++ b/.github/workflows/close-issues-on-dev-merge.yml @@ -0,0 +1,19 @@ +# 각 레포지토리의 .github/workflows/close-issues-on-dev-merge.yml + +name: Auto Close Issues on dev merge + +on: + pull_request: + types: [closed] + +jobs: + call-reusable-workflow: + if: > + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'dev' + uses: 33-Auto/.github/.github/workflows/reusable-close-linked-issues.yml@main + # with를 통해 재사용 워크플로우의 inputs에 값을 전달합니다. + with: + pr-body: ${{ github.event.pull_request.body }} + issue-number: ${{ github.event.pull_request.number }} + secrets: inherit # 재사용 워크플로우가 GITHUB_TOKEN을 사용할 수 있도록 전달 \ No newline at end of file diff --git a/.github/workflows/pr-reminder.yml b/.github/workflows/pr-reminder.yml index 9190e01..007e757 100644 --- a/.github/workflows/pr-reminder.yml +++ b/.github/workflows/pr-reminder.yml @@ -1,13 +1,15 @@ -name: PR Reminder + name: PR Reminder -on: - schedule: - - cron: "0 0,5,8 * * *" # 아침 9시, 오후 2시, 오후 5시에 실행 (UTC 기준으로 설정해서 한국 시간에 맞춤) - workflow_dispatch: + on: + schedule: + - cron: "47 23,4,7,8,10 * * *" # 아침 8시 47분, 오후 2시 47분, 오후 4시 47분, 오후 5시 47분, 오후 7시 47분 에 실행 (UTC 기준으로 설정해서 한국 시간에 맞춤) + workflow_dispatch: -jobs: - call-reusable-reminder: - uses: 33-Auto/.github/.github/workflows/reusable-pr-reminder.yml@main - secrets: - # 해당 시크릿은 조직의 시크릿에 저장되어 있음 - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + jobs: + call-reusable-reminder: + uses: 33-Auto/.github/.github/workflows/reusable-pr-reminder.yml@main + secrets: + # 해당 시크릿은 조직의 시크릿에 저장되어 있음 + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + with: + SLACK_USER_MAP: ${{ vars.SLACK_USER_MAP }} \ No newline at end of file diff --git a/.github/workflows/request-pr-review.yml b/.github/workflows/request-pr-review.yml new file mode 100644 index 0000000..66a31b9 --- /dev/null +++ b/.github/workflows/request-pr-review.yml @@ -0,0 +1,17 @@ +# 각 레포지토리의 .github/workflows/request-pr-review.yml + +name: PR Assignee & Team Review Request + +on: + pull_request: + types: [opened, reopened, ready_for_review] + +jobs: + call-reusable-workflow: + uses: 33-Auto/.github/.github/workflows/reusable-pr-assign-and-review.yml@main + with: + team-slug-for-review: "review_avengers" # 여기에 리뷰를 요청할 팀의 slug를 입력합니다. + pr-author: ${{ github.event.pull_request.user.login }} + pr-number: ${{ github.event.pull_request.number }} + secrets: + ORGANIZATION_TOKEN: ${{ secrets.ORGANIZATION_TOKEN }} # 재사용 워크플로우가 ORGANIZATION_TOKEN을 사용할 수 있도록 전달 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5eb1bb8..7d05d41 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,13 @@ import java.io.FileInputStream import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + id("com.google.devtools.ksp") + id("com.google.dagger.hilt.android") } // Keystore.properties @@ -27,10 +30,10 @@ android { defaultConfig { applicationId = "com.sampoom.android" - minSdk = 24 + minSdk = 26 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.0.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -48,17 +51,51 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" + isCoreLibraryDesugaringEnabled = true } buildFeatures { compose = true + buildConfig = true + } +} + +// Kotlin compiler options migrated to Compiler Options DSL +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) } } dependencies { + // hilt + implementation(libs.hilt.android) + ksp(libs.hilt.android.compiler) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) + implementation(libs.androidx.hilt.navigation.compose) + + // Navigation + implementation(libs.androidx.navigation.compose) + + // Retrofit + implementation(libs.retrofit) + implementation(libs.converter.gson) + + // OKHttp + implementation(libs.logging.interceptor) + + // DataStore + implementation(libs.androidx.datastore.preferences) + + // Paging + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + + coreLibraryDesugaring(libs.desugar.jdk.libs) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.material) + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 510906a..780ee42 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..abf6dd4 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/sampoom/android/MainActivity.kt b/app/src/main/java/com/sampoom/android/MainActivity.kt index 3042527..0112e92 100644 --- a/app/src/main/java/com/sampoom/android/MainActivity.kt +++ b/app/src/main/java/com/sampoom/android/MainActivity.kt @@ -1,47 +1,38 @@ package com.sampoom.android +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.sampoom.android.ui.theme.SampoomManagementTheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.sampoom.android.app.navigation.AppNavHost +import com.sampoom.android.core.ui.theme.SampoomManagementTheme +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + lightScrim = Color.Transparent.toArgb(), + darkScrim = Color.Transparent.toArgb() + ), + navigationBarStyle = SystemBarStyle.auto( + lightScrim = Color.Transparent.toArgb(), + darkScrim = Color.Transparent.toArgb() + ) + ) + if (Build.VERSION.SDK_INT >= 29) { + window.isNavigationBarContrastEnforced = false + } setContent { SampoomManagementTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + AppNavHost() } } } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - SampoomManagementTheme { - Greeting("Android") - } } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/MainActivityViewModel.kt b/app/src/main/java/com/sampoom/android/MainActivityViewModel.kt new file mode 100644 index 0000000..798b771 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/MainActivityViewModel.kt @@ -0,0 +1,27 @@ +package com.sampoom.android + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.auth.domain.model.User +import com.sampoom.android.feature.auth.domain.usecase.GetStoredUserUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainActivityViewModel @Inject constructor( + val messageHandler: GlobalMessageHandler, + private val getStoredUserUseCase: GetStoredUserUseCase +) : ViewModel() { + private val _user = MutableStateFlow(null) + val user: StateFlow = _user + + init { + viewModelScope.launch { + _user.value = getStoredUserUseCase() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/app/Application.kt b/app/src/main/java/com/sampoom/android/app/Application.kt new file mode 100644 index 0000000..bb91835 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/app/Application.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class Application : Application() \ No newline at end of file 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 new file mode 100644 index 0000000..e8f66bc --- /dev/null +++ b/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt @@ -0,0 +1,347 @@ +package com.sampoom.android.app.navigation + +import android.annotation.SuppressLint +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.core.view.WindowInsetsControllerCompat +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.sampoom.android.MainActivityViewModel +import com.sampoom.android.R +import com.sampoom.android.core.model.SnackBarMessage +import com.sampoom.android.core.ui.component.TopSnackBarHost +import com.sampoom.android.core.ui.component.rememberCommonSnackBarHostState +import com.sampoom.android.core.ui.theme.Main100 +import com.sampoom.android.core.ui.theme.Main500 +import com.sampoom.android.core.ui.theme.backgroundCardColor +import com.sampoom.android.core.ui.theme.backgroundColor +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.feature.auth.domain.model.User +import com.sampoom.android.feature.auth.ui.AuthViewModel +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.dashboard.ui.DashboardScreen +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 +import com.sampoom.android.feature.setting.ui.SettingScreen +import kotlinx.coroutines.flow.filterNotNull + +// Auth Screen +const val ROUTE_LOGIN = "login" +const val ROUTE_SIGNUP = "signup" +const val ROUTE_HOME = "home" + +// Main Screen +const val ROUTE_DASHBOARD = "dashboard" +const val ROUTE_OUTBOUND = "outbound" +const val ROUTE_CART = "cart" +const val ROUTE_ORDERS = "orders" + +// Detail Screen +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" + +sealed class BottomNavItem( + val route: String, + val title: Int, + val icon: Int +) { + object Dashboard : BottomNavItem(ROUTE_DASHBOARD, R.string.nav_dashboard, R.drawable.dashboard) + object Delivery : BottomNavItem(ROUTE_OUTBOUND, R.string.nav_delivery, R.drawable.outbound) + object Cart : BottomNavItem(ROUTE_CART, R.string.nav_cart, R.drawable.cart) + object Orders : BottomNavItem(ROUTE_ORDERS, R.string.nav_order, R.drawable.orders) +} + +@SuppressLint("ContextCastToActivity") +@Composable +fun AppNavHost( + viewModel: MainActivityViewModel = hiltViewModel() +) { + val navController = rememberNavController() + val authViewModel: AuthViewModel = hiltViewModel() + val isLoggedIn by authViewModel.isLoggedIn.collectAsState() + val isLoading by authViewModel.isLoading.collectAsState() + val user by viewModel.user.collectAsStateWithLifecycle() + val snackBarHostState = rememberCommonSnackBarHostState() + var currentMessage by remember { mutableStateOf(null) } + + // 전역 에러 수집 + LaunchedEffect(viewModel.messageHandler) { + viewModel.messageHandler.message + .filterNotNull() + .collect { message -> + currentMessage = message + snackBarHostState.showSnackbar( + message = message.message, + duration = SnackbarDuration.Short + ) + } + } + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + return + } + + NavHost( + navController = navController, +// startDestination = ROUTE_HOME, + startDestination = if (isLoggedIn) ROUTE_HOME else ROUTE_LOGIN, + modifier = Modifier.background(backgroundColor()) + ) { + composable(ROUTE_LOGIN) { + LoginScreen( + onSuccess = { + authViewModel.updateLoginState() + navController.navigate(ROUTE_HOME) { + popUpTo(ROUTE_LOGIN) { inclusive = true } // 로그인 화면 스택 제거 + } + }, + onNavigateSignUp = { + navController.navigate(ROUTE_SIGNUP) + }) + } + composable(ROUTE_SIGNUP) { + SignUpScreen( + onSuccess = { + navController.navigate(ROUTE_HOME) { + popUpTo(ROUTE_LOGIN) { inclusive = true } + } + }, + onNavigateBack = { + navController.navigateUp() + } + ) + } + composable(ROUTE_HOME) { MainScreen(navController, user) } + composable(ROUTE_PARTS) { + PartScreen( + onNavigateBack = { + navController.navigateUp() + }, + onNavigatePartList = { group -> + navController.currentBackStackEntry?.savedStateHandle?.set("groupName", group.name) + val agencyId = user?.agencyId + if (agencyId != null) navController.navigate(routePartList(agencyId, group.id)) + } + ) + } + composable( + ROUTE_PART_LIST, + arguments = listOf( + navArgument("agencyId") { type = NavType.LongType }, + navArgument("groupId") { type = NavType.LongType } + ) + ) { + PartListScreen( + onNavigateBack = { + navController.navigateUp() + }, + navController = navController + ) + } + composable( + ROUTE_ORDER_DETAIL, + arguments = listOf( + navArgument("agencyId") { type = NavType.LongType }, + navArgument("orderId") { type = NavType.LongType } + ) + ) { + OrderDetailScreen( + onNavigateBack = { + navController.navigateUp() + } + ) + } + composable( + ROUTE_SETTINGS + ) { + SettingScreen( + onNavigateBack = { + navController.navigateUp() + }, + onLogoutClick = { + authViewModel.signOut() + navController.navigate(ROUTE_LOGIN) { + popUpTo(navController.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + } + } + ) + } + } + TopSnackBarHost(hostState = snackBarHostState, isError = currentMessage?.isError ?: false) +} + +@Composable +fun MainScreen( + parentNavController: NavHostController, + user: User? +) { + val navController = rememberNavController() + + Scaffold( + floatingActionButton = { PartsFab(parentNavController) }, + bottomBar = { BottomNavigationBar(navController) } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = ROUTE_DASHBOARD + ) { + composable(ROUTE_DASHBOARD) { + DashboardScreen( + paddingValues = innerPadding, + onSettingClick = { + parentNavController.navigate(ROUTE_SETTINGS) + }, + onNavigateOrderDetail = { order -> + val agencyId = user?.agencyId + if (agencyId != null) parentNavController.navigate(routeOrderDetail(agencyId, order.orderId)) + }, + onNavigationOrder = { + navController.navigate(ROUTE_ORDERS) { + popUpTo(ROUTE_DASHBOARD) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + ) + } + composable(ROUTE_OUTBOUND) { + OutboundListScreen( + paddingValues = innerPadding + ) + } + composable(ROUTE_CART) { + CartListScreen( + paddingValues = innerPadding + ) + } + composable(ROUTE_ORDERS) { + OrderListScreen( + paddingValues = innerPadding, + onNavigateOrderDetail = { order -> + val agencyId = user?.agencyId + if (agencyId != null) parentNavController.navigate(routeOrderDetail(agencyId, order.orderId)) + } + ) + } + } + } +} + +@Composable +fun PartsFab(navController: NavHostController) { + FloatingActionButton( + containerColor = Main500, + onClick = { + navController.navigate(ROUTE_PARTS) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) { + Icon( + painterResource(R.drawable.parts), + contentDescription = stringResource(R.string.part_title), + tint = Color.White + ) + } +} + +@Composable +fun BottomNavigationBar(navController: NavHostController) { + val bottomNavItems = listOf( + BottomNavItem.Dashboard, + BottomNavItem.Delivery, + BottomNavItem.Cart, + BottomNavItem.Orders, + ) + + NavigationBar( + containerColor = backgroundCardColor(), + contentColor = Main500, + ) { + bottomNavItems.forEach { item -> + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + NavigationBarItem( + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Main500, + unselectedIconColor = textColor(), + selectedTextColor = Main500, + unselectedTextColor = textColor(), + indicatorColor = Main100 + ), + icon = { + Icon( + painterResource(id = item.icon), + contentDescription = stringResource(item.title) + ) + }, + label = { Text(stringResource(item.title)) }, + selected = currentDestination?.route == item.route, + onClick = { + navController.navigate(item.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/di/NetworkModule.kt b/app/src/main/java/com/sampoom/android/core/di/NetworkModule.kt new file mode 100644 index 0000000..31dff36 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/di/NetworkModule.kt @@ -0,0 +1,77 @@ +package com.sampoom.android.core.di + +import com.google.gson.FieldNamingPolicy +import com.google.gson.GsonBuilder +import com.sampoom.android.BuildConfig +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.core.network.TokenAuthenticator +import com.sampoom.android.core.network.TokenInterceptor +import com.sampoom.android.core.network.TokenRefreshService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Provides + @Singleton + fun provideTokenInterceptor( + authPreferences: AuthPreferences + ): TokenInterceptor { + return TokenInterceptor(authPreferences) + } + + @Provides + @Singleton + fun provideTokenRefreshService( + authPreferences: AuthPreferences + ): TokenRefreshService { + return TokenRefreshService(authPreferences) + } + + @Provides + @Singleton + fun provideOkHttpClient( + tokenInterceptor: TokenInterceptor, + tokenAuthenticator: TokenAuthenticator + ): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) + HttpLoggingInterceptor.Level.BODY + else + HttpLoggingInterceptor.Level.NONE + redactHeader("Authorization") // 토큰 비식별화 + redactHeader("Cookie") // 쿠키 비식별화 + } + ) + .addInterceptor(tokenInterceptor) // 토큰 자동 삽입 + .authenticator(tokenAuthenticator) // 토큰 갱신 (Interceptor 대신) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(client: OkHttpClient): Retrofit { + val gson = GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.IDENTITY) + .create() + return Retrofit.Builder() + .baseUrl("https://sampoom.store/api/") + .client(client) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/di/UiModule.kt b/app/src/main/java/com/sampoom/android/core/di/UiModule.kt new file mode 100644 index 0000000..696142c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/di/UiModule.kt @@ -0,0 +1,16 @@ +package com.sampoom.android.core.di + +import com.sampoom.android.core.util.GlobalMessageHandler +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UiModule { + @Provides + @Singleton + fun provideGlobalErrorHandler(): GlobalMessageHandler = GlobalMessageHandler() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/model/ApiResponse.kt b/app/src/main/java/com/sampoom/android/core/model/ApiResponse.kt new file mode 100644 index 0000000..2d5959e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/model/ApiResponse.kt @@ -0,0 +1,19 @@ +package com.sampoom.android.core.model + +data class ApiResponse( + val status: Int, + val success: Boolean, + val message: String, + val data: T +) + +data class ApiSuccessResponse( + val status: Int, + val success: Boolean, + val message: String +) + +data class ApiErrorResponse( + val code: Int? = null, + val message: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/model/SnackBarMessage.kt b/app/src/main/java/com/sampoom/android/core/model/SnackBarMessage.kt new file mode 100644 index 0000000..d27c21e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/model/SnackBarMessage.kt @@ -0,0 +1,6 @@ +package com.sampoom.android.core.model + +data class SnackBarMessage( + val message: String, + val isError: Boolean = false +) diff --git a/app/src/main/java/com/sampoom/android/core/model/UserPosition.kt b/app/src/main/java/com/sampoom/android/core/model/UserPosition.kt new file mode 100644 index 0000000..82e6f14 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/model/UserPosition.kt @@ -0,0 +1,14 @@ +package com.sampoom.android.core.model + +enum class UserPosition { + STAFF, // 사원 + SENIOR_STAFF, // 주임 + ASSISTANT_MANAGER, // 대리 + MANAGER, // 과장 + DEPUTY_GENERAL_MANAGER, // 차장 + GENERAL_MANAGER, // 부장 + DIRECTOR, // 이사 + VICE_PRESIDENT, // 부사장 + PRESIDENT, // 사장 + CHAIRMAN // 회장 +} \ 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 new file mode 100644 index 0000000..516ea2f --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/network/ErrorHandling.kt @@ -0,0 +1,24 @@ +package com.sampoom.android.core.network + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.sampoom.android.core.model.ApiErrorResponse +import retrofit2.HttpException + +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) { + null + } catch (_: Exception) { + null + } + } + return null +} + + diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt b/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt new file mode 100644 index 0000000..731fe0a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt @@ -0,0 +1,92 @@ +package com.sampoom.android.core.network + +import com.sampoom.android.core.preferences.AuthPreferences +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenAuthenticator @Inject constructor( + private val authPreferences: AuthPreferences, + private val tokenRefreshService: TokenRefreshService +) : Authenticator { + private val refreshMutex = Mutex() + + override fun authenticate(route: Route?, response: Response): Request? { + // 이미 재시도된 요청인지 확인 + if (response.request.header("X-Retry-Count") != null) { + return null // 재시도 제한 + } + + return try { + val newUser = runBlocking { + refreshMutex.withLock { + tokenRefreshService.refreshToken().getOrThrow() + } + } + + response.request.newBuilder() + .removeHeader("Authorization") + .addHeader("Authorization", "Bearer ${newUser.accessToken}") + .addHeader("X-Retry-Count", "1") + .build() + } catch (e: retrofit2.HttpException) { + // HTTP 오류별 분기 처리 + when (e.code()) { + 400, 401 -> { + // 인증 실패: 토큰 삭제 + runBlocking { authPreferences.clear() } + null + } + 403 -> { + // 권한 없음: 토큰 삭제 + runBlocking { authPreferences.clear() } + null + } + 429 -> { + // Rate Limit: 토큰 보존, 재시도는 호출자 판단 + null + } + in 500..599 -> { + // 서버 오류: 토큰 보존 + null + } + else -> { + // 기타 HTTP 오류: 토큰 보존 + null + } + } + } catch (e: java.io.IOException) { + // 네트워크 일시 오류: 토큰 보존, 재시도는 호출자 판단 + null + } catch (e: java.net.SocketTimeoutException) { + // 타임아웃: 토큰 보존 + null + } catch (e: java.net.UnknownHostException) { + // DNS 오류: 토큰 보존 + null + } catch (e: java.net.ConnectException) { + // 연결 오류: 토큰 보존 + null + } catch (t: Throwable) { + // 기타 예외: 토큰 보존 + null + } + } + + private suspend fun isTokenExpired(token: String): Boolean { + // 간단한 토큰 만료 체크 (JWT 디코딩 없이) + return try { + val expiresAt = authPreferences.isTokenExpired() + expiresAt + } catch (e: Exception) { + true // 파싱 실패 시 만료로 간주 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt b/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt new file mode 100644 index 0000000..4e79a97 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt @@ -0,0 +1,38 @@ +package com.sampoom.android.core.network + +import com.sampoom.android.core.preferences.AuthPreferences +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class TokenInterceptor @Inject constructor( + private val authPreferences: AuthPreferences +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + // 스킵 플래그가 있으면 토큰 주입 없이 진행 + if (originalRequest.header("X-No-Auth") == "true") { + val requestWithoutFlag = originalRequest.newBuilder() + .removeHeader("X-No-Auth") + .build() + return chain.proceed(requestWithoutFlag) + } + + val existingAuth = originalRequest.header("Authorization") + if (existingAuth.isNullOrBlank()) { + val accessToken = runBlocking { + authPreferences.getAccessToken() + } + if (!accessToken.isNullOrEmpty()) { + val newRequest = originalRequest.newBuilder() + .header("Authorization", "Bearer $accessToken") + .build() + return chain.proceed(newRequest) + } + } + + return chain.proceed(originalRequest) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenRefreshService.kt b/app/src/main/java/com/sampoom/android/core/network/TokenRefreshService.kt new file mode 100644 index 0000000..d2db843 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/network/TokenRefreshService.kt @@ -0,0 +1,41 @@ +package com.sampoom.android.core.network + +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.feature.auth.data.remote.api.AuthApi +import com.sampoom.android.feature.auth.data.remote.dto.RefreshRequestDto +import com.sampoom.android.feature.auth.domain.model.User +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenRefreshService @Inject constructor( + private val authPreferences: AuthPreferences +) { + suspend fun refreshToken(): Result = runCatching { + val refreshToken = authPreferences.getRefreshToken() + ?: throw Exception("No refresh token available") + + // 새로운 Retrofit 인스턴스 생성 (인터셉터 없이) + val retrofit = Retrofit.Builder() + .baseUrl("https://sampoom.store/api/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val authApi = retrofit.create(AuthApi::class.java) + val response = authApi.refresh(RefreshRequestDto(refreshToken)) + + val existingUser = authPreferences.getStoredUser() + ?: throw Exception("No user information available") + + val updatedUser = existingUser.copy( + accessToken = response.data.accessToken, + refreshToken = response.data.refreshToken, + expiresIn = response.data.expiresIn + ) + + authPreferences.saveUser(updatedUser) + updatedUser + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/preferences/AuthPreferences.kt b/app/src/main/java/com/sampoom/android/core/preferences/AuthPreferences.kt new file mode 100644 index 0000000..5b3faeb --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/preferences/AuthPreferences.kt @@ -0,0 +1,149 @@ +package com.sampoom.android.core.preferences + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.sampoom.android.core.model.UserPosition +import com.sampoom.android.feature.auth.domain.model.User +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first + +// Per official guidance, DataStore instance should be single and at top-level. +private val Context.authDataStore by preferencesDataStore(name = "auth_prefs") + +@Singleton +class AuthPreferences @Inject constructor( + @param:ApplicationContext private val context: Context, + private val cryptoManager: CryptoManager +) { + private val dataStore = context.authDataStore + + private object Keys { + val ACCESS_TOKEN: Preferences.Key = stringPreferencesKey("access_token") + val REFRESH_TOKEN: Preferences.Key = stringPreferencesKey("refresh_token") + val TOKEN_EXPIRES_AT: Preferences.Key = longPreferencesKey("token_expires_at") + val USER_ID: Preferences.Key = stringPreferencesKey("user_id") + val USER_NAME: Preferences.Key = stringPreferencesKey("user_name") + val USER_EMAIL: Preferences.Key = stringPreferencesKey("user_email") + val USER_ROLE: Preferences.Key = stringPreferencesKey("user_role") + val USER_POSITION: Preferences.Key = stringPreferencesKey("user_position") + val USER_WORKSPACE: Preferences.Key = stringPreferencesKey("user_workspace") + val USER_BRANCH: Preferences.Key = stringPreferencesKey("user_branch") + val USER_AGENCY_ID: Preferences.Key = stringPreferencesKey("user_agency_id") + val USER_STARTED_AT: Preferences.Key = stringPreferencesKey("user_started_at") + val USER_ENDED_AT: Preferences.Key = stringPreferencesKey("user_ended_at") + } + + suspend fun saveUser(user: User) { + val expiresAt = System.currentTimeMillis() + (user.expiresIn * 1000) + dataStore.edit { prefs -> + prefs[Keys.ACCESS_TOKEN] = cryptoManager.encrypt(user.accessToken) + prefs[Keys.REFRESH_TOKEN] = cryptoManager.encrypt(user.refreshToken) + prefs[Keys.TOKEN_EXPIRES_AT] = expiresAt + prefs[Keys.USER_ID] = cryptoManager.encrypt(user.userId.toString()) + prefs[Keys.USER_NAME] = cryptoManager.encrypt(user.userName) + prefs[Keys.USER_EMAIL] = cryptoManager.encrypt(user.email) + prefs[Keys.USER_ROLE] = cryptoManager.encrypt(user.role) + prefs[Keys.USER_POSITION] = cryptoManager.encrypt(user.position.name) + prefs[Keys.USER_WORKSPACE] = cryptoManager.encrypt(user.workspace) + prefs[Keys.USER_BRANCH] = cryptoManager.encrypt(user.branch) + prefs[Keys.USER_AGENCY_ID] = cryptoManager.encrypt(user.agencyId.toString()) + prefs[Keys.USER_STARTED_AT] = cryptoManager.encrypt(user.startedAt.toString()) + prefs[Keys.USER_ENDED_AT] = cryptoManager.encrypt(user.endedAt.toString()) + } + } + + suspend fun saveToken(accessToken: String, refreshToken: String, expiresIn: Long) { + val expiresAt = System.currentTimeMillis() + (expiresIn * 1000) + dataStore.edit { prefs -> + prefs[Keys.ACCESS_TOKEN] = cryptoManager.encrypt(accessToken) + prefs[Keys.REFRESH_TOKEN] = cryptoManager.encrypt(refreshToken) + prefs[Keys.TOKEN_EXPIRES_AT] = expiresAt + } + } + + suspend fun getStoredUser(): User? { + val prefs = dataStore.data.first() + val userId = prefs[Keys.USER_ID] + val userName = prefs[Keys.USER_NAME] + val userEmail = prefs[Keys.USER_EMAIL] + val userRole = prefs[Keys.USER_ROLE] + val accessToken = prefs[Keys.ACCESS_TOKEN] + val refreshToken = prefs[Keys.REFRESH_TOKEN] + val expiresAt = prefs[Keys.TOKEN_EXPIRES_AT] + val userPosition = prefs[Keys.USER_POSITION] + val userWorkspace = prefs[Keys.USER_WORKSPACE] + val userBranch = prefs[Keys.USER_BRANCH] + val userAgencyId = prefs[Keys.USER_AGENCY_ID] + val userStartedAt = prefs[Keys.USER_STARTED_AT] + val userEndedAt = prefs[Keys.USER_ENDED_AT] + + if (userId != null && userName != null && userEmail != null && userRole != null && + accessToken != null && refreshToken != null && userPosition != null && userWorkspace != null && userBranch != null && userAgencyId != null && userStartedAt != null && userEndedAt != null + ) { + try { + val remaining = expiresAt?.let { + kotlin.math.max(0L, (it - System.currentTimeMillis()) / 1000) + } ?: 0L + + return User( + cryptoManager.decrypt(userId).toLong(), + cryptoManager.decrypt(userName), + cryptoManager.decrypt(userEmail), + cryptoManager.decrypt(userRole), + cryptoManager.decrypt(accessToken), + cryptoManager.decrypt(refreshToken), + remaining, + cryptoManager.decrypt(userPosition).let { decrypted -> + try { UserPosition.valueOf(decrypted.uppercase()) } catch (_: Exception) { UserPosition.STAFF } + }, + cryptoManager.decrypt(userWorkspace), + cryptoManager.decrypt(userBranch), + cryptoManager.decrypt(userAgencyId).toLong(), + cryptoManager.decrypt(userStartedAt), + cryptoManager.decrypt(userEndedAt), + ) + } catch (e: Exception) { + return null + } + } else return null + } + + suspend fun getAccessToken(): String? { + val encrypted = dataStore.data.first()[Keys.ACCESS_TOKEN] ?: return null + return try { + cryptoManager.decrypt(encrypted) + } catch (e: Exception) { + null + } + } + + suspend fun getRefreshToken(): String? { + val encrypted = dataStore.data.first()[Keys.REFRESH_TOKEN] ?: return null + return try { + cryptoManager.decrypt(encrypted) + } catch (e: Exception) { + null + } + } + + suspend fun isTokenExpired(): Boolean { + val expiresAt = dataStore.data.first()[Keys.TOKEN_EXPIRES_AT] + return expiresAt == null || System.currentTimeMillis() > expiresAt + } + + suspend fun clear() { + dataStore.edit { it.clear() } + } + + suspend fun hasToken(): Boolean { + val accessToken = getAccessToken() + val refreshToken = getRefreshToken() + return !accessToken.isNullOrEmpty() && !refreshToken.isNullOrEmpty() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/preferences/CryptoManager.kt b/app/src/main/java/com/sampoom/android/core/preferences/CryptoManager.kt new file mode 100644 index 0000000..7d63e67 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/preferences/CryptoManager.kt @@ -0,0 +1,62 @@ +package com.sampoom.android.core.preferences + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CryptoManager @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private val keyAlias = "AuthTokenKey" + private val keyStore = KeyStore.getInstance("AndroidKeyStore") + + init { + keyStore.load(null) + createKeyIfNeeded() + } + + private fun createKeyIfNeeded() { + if (!keyStore.containsAlias(keyAlias)) { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .build() + keyGenerator.init(keyGenParameterSpec) + keyGenerator.generateKey() + } + } + + fun encrypt(plaintext: String): String { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, keyStore.getKey(keyAlias, null)) + val iv = cipher.iv + val encrypted = cipher.doFinal(plaintext.toByteArray()) + return Base64.encodeToString(iv + encrypted, Base64.DEFAULT) + } + + fun decrypt(encryptedText: String): String { + val encrypted = Base64.decode(encryptedText, Base64.DEFAULT) + val iv = encrypted.sliceArray(0..11) + val ciphertext = encrypted.sliceArray(12 until encrypted.size) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, keyStore.getKey(keyAlias, null), spec) + return String(cipher.doFinal(ciphertext)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/ui/component/CommonButton.kt b/app/src/main/java/com/sampoom/android/core/ui/component/CommonButton.kt new file mode 100644 index 0000000..ce4ed82 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/component/CommonButton.kt @@ -0,0 +1,257 @@ +package com.sampoom.android.core.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sampoom.android.core.ui.theme.FailRed +import com.sampoom.android.core.ui.theme.Main500 +import com.sampoom.android.core.ui.theme.White +import com.sampoom.android.core.ui.theme.disableColor +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.core.ui.theme.textSecondaryColor + +/** + * Sampoom common button with multiple visual variants. + * + * Usage + * ----- + * CommonButton( + * text = "Button", + * variant = ButtonVariant.Primary, + * onClick = { ... } + * ) + * + * Optionally pass a leading icon: + * CommonButton( + * text = "Button", + * variant = ButtonVariant.Primary, + * leadingIcon = { Icon(painterResource(R.drawable.parts), contentDescription = null) }, + * onClick = { ... } + * ) + */ +@Composable +fun CommonButton( + modifier: Modifier = Modifier, + enabled: Boolean = true, + variant: ButtonVariant = ButtonVariant.Primary, + size: ButtonSize = ButtonSize.Large, + leadingIcon: (@Composable (() -> Unit))? = null, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit +) { + val shape = MaterialTheme.shapes.large + val height = when (size) { + ButtonSize.Large -> 56.dp + ButtonSize.Medium -> 48.dp + ButtonSize.Small -> 40.dp + } + + when (variant) { + ButtonVariant.Primary -> { + Button( + onClick = onClick, + enabled = enabled, + shape = shape, + modifier = modifier.height(height), + colors = ButtonDefaults.buttonColors( + containerColor = Main500, + contentColor = White, + disabledContainerColor = disableColor(), + disabledContentColor = textSecondaryColor() + ) + ) { + if (leadingIcon != null) leadingIcon() + content() + } + } + + // Light/secondary (tonal) filled button + ButtonVariant.Secondary -> { + FilledTonalButton( + onClick = onClick, + enabled = enabled, + shape = shape, + modifier = modifier.height(height), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = Main500, + contentColor = White, + disabledContainerColor = disableColor(), + disabledContentColor = textSecondaryColor() + ) + ) { + if (leadingIcon != null) leadingIcon() + content() + } + } + + // Outlined with primary border + ButtonVariant.Outlined -> { + OutlinedButton( + onClick = onClick, + enabled = enabled, + shape = shape, + modifier = modifier.height(height), + border = BorderStroke(1.dp, Main500), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Main500, + disabledContentColor = textSecondaryColor() + ) + ) { + if (leadingIcon != null) leadingIcon() + content() + } + } + + // Ghost: no container, subtle onSurface content + ButtonVariant.Ghost -> { + TextButton( + onClick = onClick, + enabled = enabled, + shape = shape, + modifier = modifier.height(height), + colors = ButtonDefaults.textButtonColors( + contentColor = textSecondaryColor(), + disabledContentColor = textSecondaryColor() + ) + ) { + if (leadingIcon != null) leadingIcon() + content() + } + } + + // Destructive/Neutral (dark) filled – matches the black fill example + ButtonVariant.Neutral -> { + Button( + onClick = onClick, + enabled = enabled, + shape = shape, + modifier = modifier.height(height), + colors = ButtonDefaults.buttonColors( + containerColor = disableColor(), + contentColor = textColor(), + disabledContainerColor = disableColor(), + disabledContentColor = textSecondaryColor() + ) + ) { + if (leadingIcon != null) leadingIcon() + content() + } + } + + ButtonVariant.Error -> { + Button( + onClick = onClick, + enabled = enabled, + shape = shape, + modifier = modifier.height(height), + colors = ButtonDefaults.buttonColors( + containerColor = FailRed, + contentColor = White, + disabledContainerColor = disableColor(), + disabledContentColor = textSecondaryColor() + ) + ) { + if (leadingIcon != null) leadingIcon() + content() + } + } + } +} + +enum class ButtonVariant { + /** Primary filled (purple in design). Accepts optional [leadingIcon]. */ + Primary, + + /** Filled tonal (light purple in design). */ + Secondary, + + /** Outlined with primary border. */ + Outlined, + + /** No container; text only. */ + Ghost, + + /** Solid dark/neutral fill. */ + Neutral, + + Error +} + +enum class ButtonSize { Large, Medium, Small } + +@Preview(showBackground = true, backgroundColor = 0xFF0F0F10) +@Composable +private fun CommonButtonPreview_All() { + // Primary + CommonButton( + content = { Text("Button", fontWeight = FontWeight.Bold) }, + variant = ButtonVariant.Primary, + onClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF0F0F10) +@Composable +private fun CommonButtonPreview_Primary_WithIcon() { + CommonButton( + content = { Text("Button", fontWeight = FontWeight.Bold) }, + variant = ButtonVariant.Primary, + leadingIcon = { Icon(painterResource(android.R.drawable.ic_menu_call), contentDescription = null) }, + onClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF0F0F10) +@Composable +private fun CommonButtonPreview_Tonal() { + CommonButton( + content = { Text("Button", fontWeight = FontWeight.Bold) }, + variant = ButtonVariant.Secondary, + onClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF0F0F10) +@Composable +private fun CommonButtonPreview_Outlined() { + CommonButton( + content = { Text("Button", fontWeight = FontWeight.Bold) }, + variant = ButtonVariant.Outlined, + onClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF0F0F10) +@Composable +private fun CommonButtonPreview_Ghost() { + CommonButton( + content = { Text("Button", fontWeight = FontWeight.Bold) }, + variant = ButtonVariant.Ghost, + onClick = {} + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF0F0F10) +@Composable +private fun CommonButtonPreview_Neutral_Disabled() { + CommonButton( + content = { Text("Button", fontWeight = FontWeight.Bold) }, + variant = ButtonVariant.Neutral, + enabled = false, + onClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/ui/component/CommonSnackBar.kt b/app/src/main/java/com/sampoom/android/core/ui/component/CommonSnackBar.kt new file mode 100644 index 0000000..8eadee9 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/component/CommonSnackBar.kt @@ -0,0 +1,95 @@ +package com.sampoom.android.core.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +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.White +import com.sampoom.android.core.ui.theme.backgroundCardColor +import com.sampoom.android.core.ui.theme.textColor + +@Composable +fun rememberCommonSnackBarHostState(): SnackbarHostState = remember { SnackbarHostState() } + +@Composable +fun TopSnackBarHost( + hostState: SnackbarHostState, + extraTopPadding: Dp = 16.dp, + showDismissButton: Boolean = true, + isError: Boolean = false +) { + Box(modifier = Modifier.fillMaxSize()) { + val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + SnackbarHost( + hostState = hostState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = statusBarTop + extraTopPadding, start = 16.dp, end = 16.dp), + snackbar = { data -> + Surface( + color = if (isError) { + FailRed + } else { + backgroundCardColor() + }, + contentColor = if (isError) White else textColor(), + shadowElevation = 4.dp, + shape = RoundedCornerShape(12.dp), + tonalElevation = 6.dp, + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .wrapContentWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(data.visuals.message) + Spacer(Modifier.width(12.dp)) + val label = data.visuals.actionLabel + if (label != null) { + TextButton(onClick = { data.performAction() }) { + Text(text = label, color = textColor()) + } + Spacer(Modifier.width(8.dp)) + } + if (showDismissButton) { + IconButton(onClick = { data.dismiss() }) { + Icon( + painter = painterResource(id = R.drawable.outline_close), + contentDescription = stringResource(R.string.common_close), + tint = if (isError) White else textColor() + ) + } + } + } + } + } + ) + } +} + + diff --git a/app/src/main/java/com/sampoom/android/core/ui/component/CommonTextField.kt b/app/src/main/java/com/sampoom/android/core/ui/component/CommonTextField.kt new file mode 100644 index 0000000..33769b5 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/component/CommonTextField.kt @@ -0,0 +1,202 @@ +package com.sampoom.android.core.ui.component + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sampoom.android.core.ui.theme.FailRed +import com.sampoom.android.core.ui.theme.Main500 +import com.sampoom.android.core.ui.theme.backgroundColor + +enum class TextFieldVariant { Outlined, Filled } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CommonTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + enabled: Boolean = true, + isPassword: Boolean = false, + variant: TextFieldVariant = TextFieldVariant.Outlined, + isError: Boolean = false, + errorMessage: String? = null, + imeAction: ImeAction = ImeAction.Next, + keyboardActions: KeyboardActions = KeyboardActions(), + readOnly: Boolean = false, + singleLine: Boolean = true +) { + var passwordVisible by remember { mutableStateOf(false) } + + val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black + val containerColor = if (variant == TextFieldVariant.Filled) backgroundColor() else Color.Transparent + + val focusedBorderColor = if (isError) FailRed else Main500 + val unfocusedBorderColor = when { + isError -> FailRed + isSystemInDarkTheme() -> Color(0xFF666666) + else -> Color(0xFFCCCCCC) + } + + val trailingIconView = if (isPassword) { + @Composable { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = null, + tint = textColor + ) + } + } + } else null + + // 에러 메시지 표시용 + val supportingTextView = if (isError && errorMessage != null) { + @Composable { + Text( + text = errorMessage, + color = FailRed, + style = MaterialTheme.typography.bodySmall + ) + } + } else null + + when (variant) { + TextFieldVariant.Outlined -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text(text = placeholder, color = textColor.copy(alpha = 0.4f)) }, + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + readOnly = readOnly, + singleLine = singleLine, + enabled = enabled, + isError = isError, + trailingIcon = trailingIconView, + supportingText = supportingTextView, + visualTransformation = if (isPassword && !passwordVisible) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else KeyboardType.Text, imeAction = imeAction), + keyboardActions = keyboardActions, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = focusedBorderColor, + unfocusedBorderColor = unfocusedBorderColor, + errorBorderColor = FailRed, + disabledBorderColor = Color.Gray, + focusedLabelColor = focusedBorderColor, + unfocusedLabelColor = textColor.copy(alpha = 0.7f), + cursorColor = focusedBorderColor, + focusedTextColor = textColor, + unfocusedTextColor = textColor + ), + shape = MaterialTheme.shapes.large + ) + } + + TextFieldVariant.Filled -> { + TextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text(text = placeholder, color = textColor.copy(alpha = 0.4f)) }, + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + singleLine = true, + enabled = enabled, + isError = isError, + trailingIcon = trailingIconView, + supportingText = supportingTextView, + visualTransformation = if (isPassword && !passwordVisible) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else KeyboardType.Text), + colors = TextFieldDefaults.colors( + focusedContainerColor = containerColor, + unfocusedContainerColor = containerColor, + errorContainerColor = FailRed, + disabledContainerColor = containerColor.copy(alpha = 0.5f), + focusedIndicatorColor = focusedBorderColor, + unfocusedIndicatorColor = unfocusedBorderColor, + cursorColor = focusedBorderColor, + focusedTextColor = textColor, + unfocusedTextColor = textColor + ), + shape = MaterialTheme.shapes.large + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun Preview_Light_CommonTextFields() { + MaterialTheme { + Column { + CommonTextField( + value = "Example@naver.com", + onValueChange = {}, + placeholder = "Example@naver.com", + variant = TextFieldVariant.Outlined + ) + CommonTextField( + value = "", + onValueChange = {}, + placeholder = "비밀번호 입력", + isPassword = true, + variant = TextFieldVariant.Outlined + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun Preview_Dark_CommonTextFields() { + MaterialTheme(colorScheme = darkColorScheme()) { + Column { + // 정상 + CommonTextField( + value = "Example@naver.com", + onValueChange = {}, + placeholder = "이메일", + variant = TextFieldVariant.Outlined + ) + + // 에러 + CommonTextField( + value = "invalid", + onValueChange = {}, + placeholder = "이메일", + variant = TextFieldVariant.Outlined, + isError = true, + errorMessage = "올바른 이메일 형식이 아닙니다" + ) + + // 비밀번호 에러 + CommonTextField( + value = "123", + onValueChange = {}, + placeholder = "비밀번호", + isPassword = true, + variant = TextFieldVariant.Outlined, + isError = true, + errorMessage = "비밀번호는 최소 8자 이상이어야 합니다" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/ui/component/EmptyContent.kt b/app/src/main/java/com/sampoom/android/core/ui/component/EmptyContent.kt new file mode 100644 index 0000000..b522f20 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/component/EmptyContent.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.core.ui.component + +import androidx.compose.foundation.layout.Box +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 com.sampoom.android.core.ui.theme.textSecondaryColor + +@Composable +fun EmptyContent( + message: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text( + text = message, + color = textSecondaryColor(), + style = MaterialTheme.typography.bodyMedium + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/ui/component/ErrorContent.kt b/app/src/main/java/com/sampoom/android/core/ui/component/ErrorContent.kt new file mode 100644 index 0000000..201be9b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/component/ErrorContent.kt @@ -0,0 +1,36 @@ +package com.sampoom.android.core.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +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.unit.dp +import com.sampoom.android.R +import com.sampoom.android.core.ui.theme.FailRed + +@Composable +fun ErrorContent( + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.common_error), + color = FailRed + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(stringResource(R.string.common_retry)) + } + } +} \ No newline at end of file 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..3d7341d --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/component/StatusChip.kt @@ -0,0 +1,42 @@ +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.CONFIRMED -> stringResource(R.string.order_status_confirmed) to WaitYellow // 주문 확인 + OrderStatus.SHIPPING -> stringResource(R.string.order_status_shipping) to WaitYellow // 배송 중 + OrderStatus.DELAYED -> stringResource(R.string.order_status_delayed) to WaitYellow // 배송 지연 + OrderStatus.PRODUCING -> stringResource(R.string.order_status_producing) to WaitYellow // 생산 중 + OrderStatus.ARRIVED -> stringResource(R.string.order_status_arrived) 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/ui/theme/Color.kt b/app/src/main/java/com/sampoom/android/core/ui/theme/Color.kt new file mode 100644 index 0000000..fe07c9a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/theme/Color.kt @@ -0,0 +1,263 @@ +package com.sampoom.android.core.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF4C4AC8) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFF6565E2) +val onPrimaryContainerLight = Color(0xFFFFFBFF) +val secondaryLight = Color(0xFF5B5D72) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFE6E6FF) +val onSecondaryContainerLight = Color(0xFF65667B) +val tertiaryLight = Color(0xFF5D5F5F) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFF5F5F5) +val onTertiaryContainerLight = Color(0xFF6F7070) +val errorLight = Color(0xFFAD3035) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFF6C6C) +val onErrorContainerLight = Color(0xFF6E0011) +val backgroundLight = Color(0xFFFCF8FF) +val onBackgroundLight = Color(0xFF1B1B23) +val surfaceLight = Color(0xFFFCF8F8) +val onSurfaceLight = Color(0xFF1C1B1B) +val surfaceVariantLight = Color(0xFFE0E3E3) +val onSurfaceVariantLight = Color(0xFF444748) +val outlineLight = Color(0xFF747878) +val outlineVariantLight = Color(0xFFC4C7C8) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF313030) +val inverseOnSurfaceLight = Color(0xFFF4F0EF) +val inversePrimaryLight = Color(0xFFC2C1FF) +val surfaceDimLight = Color(0xFFDDD9D9) +val surfaceBrightLight = Color(0xFFFCF8F8) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF6F3F2) +val surfaceContainerLight = Color(0xFFF1EDEC) +val surfaceContainerHighLight = Color(0xFFEBE7E7) +val surfaceContainerHighestLight = Color(0xFFE5E2E1) + +val primaryLightMediumContrast = Color(0xFF231AA2) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF5E5DDA) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF333548) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF6A6B81) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF353637) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF6C6D6D) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF730012) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFC13F42) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFCF8FF) +val onBackgroundLightMediumContrast = Color(0xFF1B1B23) +val surfaceLightMediumContrast = Color(0xFFFCF8F8) +val onSurfaceLightMediumContrast = Color(0xFF111111) +val surfaceVariantLightMediumContrast = Color(0xFFE0E3E3) +val onSurfaceVariantLightMediumContrast = Color(0xFF333738) +val outlineLightMediumContrast = Color(0xFF4F5354) +val outlineVariantLightMediumContrast = Color(0xFF6A6E6E) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF313030) +val inverseOnSurfaceLightMediumContrast = Color(0xFFF4F0EF) +val inversePrimaryLightMediumContrast = Color(0xFFC2C1FF) +val surfaceDimLightMediumContrast = Color(0xFFC9C6C5) +val surfaceBrightLightMediumContrast = Color(0xFFFCF8F8) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF6F3F2) +val surfaceContainerLightMediumContrast = Color(0xFFEBE7E7) +val surfaceContainerHighLightMediumContrast = Color(0xFFDFDCDB) +val surfaceContainerHighestLightMediumContrast = Color(0xFFD4D1D0) + +val primaryLightHighContrast = Color(0xFF160299) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF3835B4) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF292B3D) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF46485C) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF2A2C2D) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF48494A) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF60000D) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF8F1922) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFCF8FF) +val onBackgroundLightHighContrast = Color(0xFF1B1B23) +val surfaceLightHighContrast = Color(0xFFFCF8F8) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFE0E3E3) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF292D2D) +val outlineVariantLightHighContrast = Color(0xFF464A4A) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF313030) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFC2C1FF) +val surfaceDimLightHighContrast = Color(0xFFBBB8B7) +val surfaceBrightLightHighContrast = Color(0xFFFCF8F8) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF4F0EF) +val surfaceContainerLightHighContrast = Color(0xFFE5E2E1) +val surfaceContainerHighLightHighContrast = Color(0xFFD7D4D3) +val surfaceContainerHighestLightHighContrast = Color(0xFFC9C6C5) + +val primaryDark = Color(0xFFC2C1FF) +val onPrimaryDark = Color(0xFF1B0E9D) +val primaryContainerDark = Color(0xFF8283FF) +val onPrimaryContainerDark = Color(0xFF12008E) +val secondaryDark = Color(0xFFFFFFFF) +val onSecondaryDark = Color(0xFF2D2F42) +val secondaryContainerDark = Color(0xFFE0E0F9) +val onSecondaryContainerDark = Color(0xFF616378) +val tertiaryDark = Color(0xFFFFFFFF) +val onTertiaryDark = Color(0xFF2F3131) +val tertiaryContainerDark = Color(0xFFE2E2E2) +val onTertiaryContainerDark = Color(0xFF636565) +val errorDark = Color(0xFFFFB3B0) +val onErrorDark = Color(0xFF68000F) +val errorContainerDark = Color(0xFFFF6C6C) +val onErrorContainerDark = Color(0xFF6E0011) +val backgroundDark = Color(0xFF13131A) +val onBackgroundDark = Color(0xFFE4E1EC) +val surfaceDark = Color(0xFF141313) +val onSurfaceDark = Color(0xFFE5E2E1) +val surfaceVariantDark = Color(0xFF444748) +val onSurfaceVariantDark = Color(0xFFC4C7C8) +val outlineDark = Color(0xFF8E9192) +val outlineVariantDark = Color(0xFF444748) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE5E2E1) +val inverseOnSurfaceDark = Color(0xFF313030) +val inversePrimaryDark = Color(0xFF4F4DCA) +val surfaceDimDark = Color(0xFF141313) +val surfaceBrightDark = Color(0xFF3A3939) +val surfaceContainerLowestDark = Color(0xFF0E0E0E) +val surfaceContainerLowDark = Color(0xFF1C1B1B) +val surfaceContainerDark = Color(0xFF201F1F) +val surfaceContainerHighDark = Color(0xFF2A2A2A) +val surfaceContainerHighestDark = Color(0xFF353434) + +val primaryDarkMediumContrast = Color(0xFFDBD9FF) +val onPrimaryDarkMediumContrast = Color(0xFF110088) +val primaryContainerDarkMediumContrast = Color(0xFF8283FF) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFFFFFFF) +val onSecondaryDarkMediumContrast = Color(0xFF2D2F42) +val secondaryContainerDarkMediumContrast = Color(0xFFE0E0F9) +val onSecondaryContainerDarkMediumContrast = Color(0xFF45475A) +val tertiaryDarkMediumContrast = Color(0xFFFFFFFF) +val onTertiaryDarkMediumContrast = Color(0xFF2F3131) +val tertiaryContainerDarkMediumContrast = Color(0xFFE2E2E2) +val onTertiaryContainerDarkMediumContrast = Color(0xFF464848) +val errorDarkMediumContrast = Color(0xFFFFD2CF) +val onErrorDarkMediumContrast = Color(0xFF54000A) +val errorContainerDarkMediumContrast = Color(0xFFFF6C6C) +val onErrorContainerDarkMediumContrast = Color(0xFF250002) +val backgroundDarkMediumContrast = Color(0xFF13131A) +val onBackgroundDarkMediumContrast = Color(0xFFE4E1EC) +val surfaceDarkMediumContrast = Color(0xFF141313) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF444748) +val onSurfaceVariantDarkMediumContrast = Color(0xFFDADDDD) +val outlineDarkMediumContrast = Color(0xFFAFB2B3) +val outlineVariantDarkMediumContrast = Color(0xFF8D9191) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE5E2E1) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF2A2A2A) +val inversePrimaryDarkMediumContrast = Color(0xFF3733B3) +val surfaceDimDarkMediumContrast = Color(0xFF141313) +val surfaceBrightDarkMediumContrast = Color(0xFF454444) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF070707) +val surfaceContainerLowDarkMediumContrast = Color(0xFF1E1D1D) +val surfaceContainerDarkMediumContrast = Color(0xFF282828) +val surfaceContainerHighDarkMediumContrast = Color(0xFF333232) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3E3D3D) + +val primaryDarkHighContrast = Color(0xFFF1EEFF) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFBDBCFF) +val onPrimaryContainerDarkHighContrast = Color(0xFF03003B) +val secondaryDarkHighContrast = Color(0xFFFFFFFF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFE0E0F9) +val onSecondaryContainerDarkHighContrast = Color(0xFF27293B) +val tertiaryDarkHighContrast = Color(0xFFFFFFFF) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFE2E2E2) +val onTertiaryContainerDarkHighContrast = Color(0xFF282A2B) +val errorDarkHighContrast = Color(0xFFFFECEA) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFADAA) +val onErrorContainerDarkHighContrast = Color(0xFF220002) +val backgroundDarkHighContrast = Color(0xFF13131A) +val onBackgroundDarkHighContrast = Color(0xFFE4E1EC) +val surfaceDarkHighContrast = Color(0xFF141313) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF444748) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFEEF0F1) +val outlineVariantDarkHighContrast = Color(0xFFC0C3C4) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE5E2E1) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF3733B3) +val surfaceDimDarkHighContrast = Color(0xFF141313) +val surfaceBrightDarkHighContrast = Color(0xFF51504F) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF201F1F) +val surfaceContainerDarkHighContrast = Color(0xFF313030) +val surfaceContainerHighDarkHighContrast = Color(0xFF3C3B3B) +val surfaceContainerHighestDarkHighContrast = Color(0xFF474646) + +val White = Color(0xFFFFFFFF) +val Black = Color(0xFF000000) + +val SuccessGreen = Color(0xFF10B981) +val FailRed = Color(0xFFFF6C6C) +val WaitYellow = Color(0xFFF59E0B) + +val Grey400 = Color(0xFF444444) +val Grey300 = Color(0xFF7C7C7C) +val Grey200 = Color(0xFFCCCCCC) +val Grey100 = Color(0xFFE9EAEC) + +val BgWhite = Color(0xFFF5F5F5) +val BgCardWhite = Color(0xFFFFFFFF) +val BgBlack = Color(0xFF17181B) +val BgCardBlack = Color(0xFF36393F) + +val Main900 = Color(0xFF1F1F5C) +val Main800 = Color(0xFF333399) +val Main700 = Color(0xFF4C4CBB) +val Main600 = Color(0xFF6666DD) +val Main500 = Color(0xFF8080FF) +val Main400 = Color(0xFF9999FF) +val Main300 = Color(0xFFB3B3FF) +val Main200 = Color(0xFFCCCCFF) +val Main100 = Color(0xFFE6E6FF) + +@Composable +fun backgroundColor() = if (isSystemInDarkTheme()) BgBlack else BgWhite + +@Composable +fun backgroundCardColor() = if (isSystemInDarkTheme()) BgCardBlack else BgCardWhite + +@Composable +fun textColor() = if (isSystemInDarkTheme()) BgWhite else BgCardBlack + +@Composable +fun textSecondaryColor() = if (isSystemInDarkTheme()) Grey200 else Grey300 + +@Composable +fun disableColor() = if (isSystemInDarkTheme()) Grey400 else Grey100 \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/ui/theme/Theme.kt b/app/src/main/java/com/sampoom/android/core/ui/theme/Theme.kt new file mode 100644 index 0000000..88e7334 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/theme/Theme.kt @@ -0,0 +1,110 @@ +package com.sampoom.android.core.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Main500, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = Main300, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = Main100, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = BgBlack, + onBackground = onBackgroundDark, + surface = BgBlack, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val LightColorScheme = lightColorScheme( + primary = Main500, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = Main300, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = Main100, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = BgWhite, + onBackground = onBackgroundLight, + surface = BgWhite, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +@Composable +fun SampoomManagementTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/ui/theme/Type.kt b/app/src/main/java/com/sampoom/android/core/ui/theme/Type.kt new file mode 100644 index 0000000..bc62309 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/theme/Type.kt @@ -0,0 +1,80 @@ +package com.sampoom.android.core.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sampoom.android.R + +val GmarketSansFamily = FontFamily( + Font(R.font.gmarket_sans_light, FontWeight.Light), + Font(R.font.gmarket_sans_medium, FontWeight.Medium), + Font(R.font.gmarket_sans_bold, FontWeight.Bold) +) + +// Set of Material typography styles to start with +private val baseTypography = Typography() +val Typography = Typography( + displayLarge = baseTypography.displayLarge.copy( + fontFamily = GmarketSansFamily, + fontSize = 43.sp + ), + displayMedium = baseTypography.displayMedium.copy( + fontFamily = GmarketSansFamily, + fontSize = 34.sp + ), + displaySmall = baseTypography.displaySmall.copy( + fontFamily = GmarketSansFamily, + fontSize = 27.sp + ), + headlineLarge = baseTypography.headlineLarge.copy( + fontFamily = GmarketSansFamily, + fontSize = 24.sp + ), + headlineMedium = baseTypography.headlineMedium.copy( + fontFamily = GmarketSansFamily, + fontSize = 21.sp + ), + headlineSmall = baseTypography.headlineSmall.copy( + fontFamily = GmarketSansFamily, + fontSize = 18.sp + ), + titleLarge = baseTypography.titleLarge.copy( + fontFamily = GmarketSansFamily, + fontSize = 16.sp + ), + titleMedium = baseTypography.titleMedium.copy( + fontFamily = GmarketSansFamily, + fontSize = 12.sp + ), + titleSmall = baseTypography.titleSmall.copy( + fontFamily = GmarketSansFamily, + fontSize = 11.sp + ), + bodyLarge = baseTypography.bodyLarge.copy( + fontFamily = GmarketSansFamily, + fontSize = 12.sp + ), + bodyMedium = baseTypography.bodyMedium.copy( + fontFamily = GmarketSansFamily, + fontSize = 12.sp + ), + bodySmall = baseTypography.bodySmall.copy( + fontFamily = GmarketSansFamily, + fontSize = 10.sp + ), + labelLarge = baseTypography.labelLarge.copy( + fontFamily = GmarketSansFamily, + fontSize = 11.sp + ), + labelMedium = baseTypography.labelMedium.copy( + fontFamily = GmarketSansFamily, + fontSize = 10.sp + ), + labelSmall = baseTypography.labelSmall.copy( + fontFamily = GmarketSansFamily, + fontSize = 9.sp + ) +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/util/DelayNetwork.kt b/app/src/main/java/com/sampoom/android/core/util/DelayNetwork.kt new file mode 100644 index 0000000..9daf43b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/util/DelayNetwork.kt @@ -0,0 +1,30 @@ +package com.sampoom.android.core.util + +import kotlinx.coroutines.delay +import java.io.IOException +import java.net.SocketTimeoutException + +suspend fun retry( + times: Int = 5, + initialDelay: Long = 300, + maxDelay: Long = 1500, + factor: Double = 1.8, + block: suspend () -> T +): T { + var currentDelay = initialDelay + repeat(times - 1) { + try { + return block() + } catch (t: Throwable) { + when (t) { + is SocketTimeoutException, is IOException -> { + + } + else -> throw t + } + } + delay(currentDelay) + currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) + } + return block() +} \ 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..b064f67 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/util/FormatDate.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.core.util + +import java.time.format.DateTimeFormatter + +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/FormatPrice.kt b/app/src/main/java/com/sampoom/android/core/util/FormatPrice.kt new file mode 100644 index 0000000..91adf0f --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/util/FormatPrice.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.core.util + +import java.text.NumberFormat +import java.util.Locale + +fun formatWon(value: Long): String = + NumberFormat.getInstance(Locale.KOREA).format(value) + "원" \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/util/GlobalMessageHandler.kt b/app/src/main/java/com/sampoom/android/core/util/GlobalMessageHandler.kt new file mode 100644 index 0000000..4533272 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/util/GlobalMessageHandler.kt @@ -0,0 +1,21 @@ +package com.sampoom.android.core.util + +import com.sampoom.android.core.model.SnackBarMessage +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GlobalMessageHandler @Inject constructor() { + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + suspend fun showMessage(message: String, isError: Boolean = false) { + _message.value = SnackBarMessage(message, isError) + delay(3000) + _message.value = null + } +} \ 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/core/util/PositionToKorean.kt b/app/src/main/java/com/sampoom/android/core/util/PositionToKorean.kt new file mode 100644 index 0000000..75f00dc --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/util/PositionToKorean.kt @@ -0,0 +1,17 @@ +package com.sampoom.android.core.util + +import com.sampoom.android.core.model.UserPosition + +fun positionToKorean(position: UserPosition?): String = when (position) { + UserPosition.STAFF -> "사원" + UserPosition.SENIOR_STAFF -> "주임" + UserPosition.ASSISTANT_MANAGER -> "대리" + UserPosition.MANAGER -> "과장" + UserPosition.DEPUTY_GENERAL_MANAGER -> "차장" + UserPosition.GENERAL_MANAGER -> "부장" + UserPosition.DIRECTOR -> "이사" + UserPosition.VICE_PRESIDENT -> "부사장" + UserPosition.PRESIDENT -> "사장" + UserPosition.CHAIRMAN -> "회장" + else -> "-" +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/mapper/AuthMappers.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/mapper/AuthMappers.kt new file mode 100644 index 0000000..206707c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/mapper/AuthMappers.kt @@ -0,0 +1,56 @@ +package com.sampoom.android.feature.auth.data.mapper + +import com.sampoom.android.core.model.UserPosition +import com.sampoom.android.feature.auth.data.remote.dto.GetProfileResponseDto +import com.sampoom.android.feature.auth.data.remote.dto.GetVendorsResponseDto +import com.sampoom.android.feature.auth.data.remote.dto.LoginResponseDto +import com.sampoom.android.feature.auth.domain.model.User +import com.sampoom.android.feature.auth.domain.model.Vendor + +fun LoginResponseDto.toModel(): User = User( + userId = userId, + userName = "", + email = "", + role = "", + accessToken = accessToken, + refreshToken = refreshToken, + expiresIn = expiresIn, + position = UserPosition.STAFF, + workspace = "", + branch = "", + agencyId = 0, + startedAt = null, + endedAt = null +) + +fun GetProfileResponseDto.toModel(): User = User( + userId = userId, + userName = userName, + email = email, + role = role, + accessToken = "", + refreshToken = "", + expiresIn = 0L, + position = position.toUserPosition(), + workspace = workspace, + branch = branch, + agencyId = organizationId, + startedAt = startedAt, + endedAt = endedAt +) + +private fun String.toUserPosition(): UserPosition = try { + UserPosition.valueOf(this.uppercase()) +} catch (_: IllegalArgumentException) { + UserPosition.STAFF +} + +fun GetVendorsResponseDto.toModel(): Vendor = Vendor( + id = id, + vendorCode = vendorCode, + name = name, + businessNumber = businessNumber, + ceoName = ceoName, + address = address, + status = status +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/api/AuthApi.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/api/AuthApi.kt new file mode 100644 index 0000000..fbbf1fb --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/api/AuthApi.kt @@ -0,0 +1,45 @@ +package com.sampoom.android.feature.auth.data.remote.api + +import com.sampoom.android.core.model.ApiResponse +import com.sampoom.android.core.model.ApiSuccessResponse +import com.sampoom.android.feature.auth.data.remote.dto.LoginRequestDto +import com.sampoom.android.feature.auth.data.remote.dto.SignUpRequestDto +import com.sampoom.android.feature.auth.data.remote.dto.SignUpResponseDto +import com.sampoom.android.feature.auth.data.remote.dto.LoginResponseDto +import com.sampoom.android.feature.auth.data.remote.dto.RefreshRequestDto +import com.sampoom.android.feature.auth.data.remote.dto.RefreshResponseDto +import com.sampoom.android.feature.auth.data.remote.dto.UpdateProfileRequestDto +import com.sampoom.android.feature.auth.data.remote.dto.UpdateProfileResponseDto +import com.sampoom.android.feature.auth.data.remote.dto.GetProfileResponseDto +import com.sampoom.android.feature.auth.data.remote.dto.GetVendorsResponseDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Query + +interface AuthApi { + @POST("auth/signup") + @Headers("X-No-Auth: true") + suspend fun signUp(@Body body: SignUpRequestDto): ApiResponse + + @POST("auth/refresh") + suspend fun refresh(@Body body: RefreshRequestDto): ApiResponse + + @POST("auth/logout") + suspend fun logout(): ApiSuccessResponse + + @POST("auth/login") + @Headers("X-No-Auth: true") + suspend fun login(@Body body: LoginRequestDto): ApiResponse + + @GET("user/profile") + suspend fun getProfile(@Query("workspace") workspace: String): ApiResponse + + @PATCH("user/profile") + suspend fun updateProfile(@Body body: UpdateProfileRequestDto): ApiResponse + + @GET("site/vendors") + suspend fun getVendors(): ApiResponse> +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/GetProfileResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/GetProfileResponseDto.kt new file mode 100644 index 0000000..aa46f7d --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/GetProfileResponseDto.kt @@ -0,0 +1,14 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class GetProfileResponseDto( + val userId: Long, + val userName: String, + val email: String, + val role: String, + val position: String, + val workspace: String, + val branch: String, + val organizationId: Long, + val startedAt: String, + val endedAt: String? +) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/GetVendorsResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/GetVendorsResponseDto.kt new file mode 100644 index 0000000..46e260f --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/GetVendorsResponseDto.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class GetVendorsResponseDto( + val id: Long, + val vendorCode: String, + val name: String, + val businessNumber: String, + val ceoName: String, + val address: String, + val status: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginRequestDto.kt new file mode 100644 index 0000000..b52f270 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginRequestDto.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class LoginRequestDto( + val workspace: String, + val email: String, + val password: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginResponseDto.kt new file mode 100644 index 0000000..67a83d1 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginResponseDto.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class LoginResponseDto( + val userId: Long, + val accessToken: String, + val refreshToken: String, + val expiresIn: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/RefreshRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/RefreshRequestDto.kt new file mode 100644 index 0000000..9589dad --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/RefreshRequestDto.kt @@ -0,0 +1,5 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class RefreshRequestDto( + val refreshToken: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/RefreshResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/RefreshResponseDto.kt new file mode 100644 index 0000000..9b12851 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/RefreshResponseDto.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class RefreshResponseDto( + val accessToken: String, + val expiresIn: Long, + val refreshToken: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpRequestDto.kt new file mode 100644 index 0000000..50cc02b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpRequestDto.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class SignUpRequestDto( + val email: String, + val password: String, + val workspace: String, + val branch: String, + val userName: String, + val position: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpResponseDto.kt new file mode 100644 index 0000000..d683a69 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpResponseDto.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class SignUpResponseDto( + val userId: Long, + val userName: String, + val email: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/UpdateProfileRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/UpdateProfileRequestDto.kt new file mode 100644 index 0000000..8db66be --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/UpdateProfileRequestDto.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class UpdateProfileRequestDto( + val userName: String, + val position: String, + val workspace: String, + val branch: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/UpdateProfileResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/UpdateProfileResponseDto.kt new file mode 100644 index 0000000..12e2ab6 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/UpdateProfileResponseDto.kt @@ -0,0 +1,9 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class UpdateProfileResponseDto( + val userId: Long, + val userName: String, + val position: String, + val workspace: String, + val branch: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..cdc8642 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,145 @@ +package com.sampoom.android.feature.auth.data.repository + +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.core.util.retry +import com.sampoom.android.feature.auth.data.mapper.toModel +import com.sampoom.android.feature.auth.data.remote.api.AuthApi +import com.sampoom.android.feature.auth.data.remote.dto.LoginRequestDto +import com.sampoom.android.feature.auth.data.remote.dto.RefreshRequestDto +import com.sampoom.android.feature.auth.data.remote.dto.SignUpRequestDto +import com.sampoom.android.feature.auth.domain.model.User +import com.sampoom.android.feature.auth.domain.model.VendorList +import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import com.sampoom.android.feature.outbound.data.mapper.toModel +import kotlinx.coroutines.delay +import javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val api: AuthApi, + private val preferences: AuthPreferences +) : AuthRepository { + + override suspend fun signUp( + email: String, + password: String, + workspace: String, + branch: String, + userName: String, + position: String + ): Result { + return runCatching { + val signUpRes = api.signUp( + SignUpRequestDto( + email = email, + password = password, + workspace = workspace, + branch = branch, + userName = userName, + position = position + ) + ) + if (!signUpRes.success) throw Exception(signUpRes.message) + + delay(500) + retry(times = 5, initialDelay = 500) { + signIn(email, password).getOrThrow() + } + } + } + + override suspend fun signIn( + email: String, + password: String + ): Result { + return runCatching { + val loginDto = api.login( + LoginRequestDto( + workspace = "AGENCY", + email = email, + password = password + ) + ) + if (!loginDto.success) throw Exception(loginDto.message) + val loginUser = loginDto.data.toModel() + + preferences.saveUser(loginUser) + + val profileUser = retry(times = 5, initialDelay = 300) { + getProfile("AGENCY").getOrThrow() + } + + val user = User( + userId = loginUser.userId, + userName = profileUser.userName, + email = profileUser.email, + role = profileUser.role, + accessToken = loginUser.accessToken, + refreshToken = loginUser.refreshToken, + expiresIn = loginUser.expiresIn, + position = profileUser.position, + workspace = profileUser.workspace, + branch = profileUser.branch, + agencyId = profileUser.agencyId, + startedAt = profileUser.startedAt, + endedAt = profileUser.endedAt + ) + + preferences.saveUser(user) + user + } + } + + override suspend fun signOut(): Result { + return runCatching { + val dto = api.logout() + if (!dto.success) throw Exception(dto.message) + }.onSuccess { + preferences.clear() + }.onFailure { + preferences.clear() + } + } + + override suspend fun refreshToken(): Result { + return runCatching { + val refreshToken = + preferences.getRefreshToken() ?: throw Exception("No refresh token available") + val response = api.refresh(RefreshRequestDto(refreshToken)) + val existingUser = + preferences.getStoredUser() ?: throw Exception("No user information available") + + val updatedUser = existingUser.copy( + accessToken = response.data.accessToken, + refreshToken = response.data.refreshToken, + expiresIn = response.data.expiresIn + ) + preferences.saveUser(updatedUser) + updatedUser + } + } + + override suspend fun clearTokens(): Result { + return runCatching { + preferences.clear() + } + } + + override suspend fun isSignedIn(): Boolean = preferences.hasToken() + + override suspend fun getProfile(workspace: String): Result { + return runCatching { + val dto = api.getProfile(workspace) + if (!dto.success) throw Exception(dto.message) + dto.data.toModel() + } + } + + override suspend fun getVendorList(): Result { + return runCatching { + val dto = api.getVendors() + if (!dto.success) throw Exception(dto.message) + val vendorItems = dto.data.map { it.toModel() } + VendorList(items = vendorItems) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/di/AuthModules.kt b/app/src/main/java/com/sampoom/android/feature/auth/di/AuthModules.kt new file mode 100644 index 0000000..87b7f09 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/di/AuthModules.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.feature.auth.di + +import com.sampoom.android.feature.auth.data.remote.api.AuthApi +import com.sampoom.android.feature.auth.data.repository.AuthRepositoryImpl +import com.sampoom.android.feature.auth.domain.repository.AuthRepository +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 AuthBinModule { + @Binds @Singleton + abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository +} + +@Module +@InstallIn(SingletonComponent::class) +object AuthProvideModule { + @Provides @Singleton + fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/AuthValidator.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/AuthValidator.kt new file mode 100644 index 0000000..b7c4516 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/AuthValidator.kt @@ -0,0 +1,72 @@ +package com.sampoom.android.feature.auth.domain + +import com.sampoom.android.R + +object AuthValidator { + // 이메일 형식 검증 + fun validateEmail(email: String): ValidationResult { + if (email.isBlank()) { + return ValidationResult.Error(R.string.validation_email_required) + } + + val emailPattern = "[a-zA-Z0-9._-]+@[a-z]+\\.+[a-z]+".toRegex() + if (!email.matches(emailPattern)) { + return ValidationResult.Error(R.string.validation_email_invalid) + } + + return ValidationResult.Success + } + + // 비밀번호 검증 (8-30자, 영문+숫자+특수문자 각 1개 이상) + fun validatePassword(password: String): ValidationResult { + if (password.isBlank()) { + return ValidationResult.Error(R.string.validation_password_required) + } + + if (password.length < 8) { + return ValidationResult.Error(R.string.validation_password_min_length) + } + + if (password.length > 30) { + return ValidationResult.Error(R.string.validation_password_max_length) + } + + val hasLetter = password.any { it.isLetter() } + val hasDigit = password.any { it.isDigit() } + val hasSpecial = password.any { !it.isLetterOrDigit() } + + if (!hasLetter || !hasDigit || !hasSpecial) { + return ValidationResult.Error(R.string.validation_password_complexity) + } + + return ValidationResult.Success + } + + // 비밀번호 확인 검증 + fun validatePasswordCheck(password: String, passwordConfirm: String): ValidationResult { + if (passwordConfirm.isBlank()) { + return ValidationResult.Error(R.string.validation_password_confirm_required) + } + + if (password != passwordConfirm) { + return ValidationResult.Error(R.string.validation_password_mismatch) + } + + return ValidationResult.Success + } + + // 일반 필드 검증 (이름, 지점, 직책 등) + fun validateNotEmpty(value: String, fieldName: String): ValidationResult { + if (value.isBlank()) { + return ValidationResult.ErrorWithArgs(R.string.validation_field_required, fieldName) + } + return ValidationResult.Success + } +} + +// 검증 결과 +sealed class ValidationResult { + object Success : ValidationResult() + data class Error(val messageResId: Int) : ValidationResult() + data class ErrorWithArgs(val messageResId: Int, val args: Any) : ValidationResult() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/model/User.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/model/User.kt new file mode 100644 index 0000000..984500a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/model/User.kt @@ -0,0 +1,19 @@ +package com.sampoom.android.feature.auth.domain.model + +import com.sampoom.android.core.model.UserPosition + +data class User( + val userId: Long, + val userName: String, + val email: String, + val role: String, + val accessToken: String, + val refreshToken: String, + val expiresIn: Long, + val position: UserPosition, + val workspace: String, + val branch: String, + val agencyId: Long, + val startedAt: String?, + val endedAt: String? +) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/model/Vendor.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/model/Vendor.kt new file mode 100644 index 0000000..2b47188 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/model/Vendor.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.auth.domain.model + +data class Vendor( + val id: Long, + val vendorCode: String, + val name: String, + val businessNumber: String, + val ceoName: String, + val address: String, + val status: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/model/VendorList.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/model/VendorList.kt new file mode 100644 index 0000000..0f3431c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/model/VendorList.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.auth.domain.model + +data class VendorList( + val items: List, + val totalCount: Int = items.size, + val isEmpty: Boolean = items.isEmpty() +) { + companion object Companion { + fun empty() = VendorList(emptyList()) + } +} diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/repository/AuthRepository.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..7d9e833 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/repository/AuthRepository.kt @@ -0,0 +1,23 @@ +package com.sampoom.android.feature.auth.domain.repository + +import com.sampoom.android.feature.auth.domain.model.User +import com.sampoom.android.feature.auth.domain.model.VendorList + +interface AuthRepository { + suspend fun signUp( + email: String, + password: String, + workspace: String, + branch: String, + userName: String, + position: String + ): Result + + suspend fun signIn(email: String, password: String): Result + suspend fun signOut(): Result + suspend fun refreshToken(): Result + suspend fun clearTokens(): Result + suspend fun isSignedIn(): Boolean + suspend fun getProfile(workspace: String): Result + suspend fun getVendorList(): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/CheckLoginStateUseCase.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/CheckLoginStateUseCase.kt new file mode 100644 index 0000000..4d2cb89 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/CheckLoginStateUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.auth.domain.usecase + +import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import javax.inject.Inject + +class CheckLoginStateUseCase @Inject constructor( + private val repository: AuthRepository +) { + suspend operator fun invoke(): Boolean = repository.isSignedIn() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/ClearTokensUseCase.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/ClearTokensUseCase.kt new file mode 100644 index 0000000..a0cc0ff --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/ClearTokensUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.auth.domain.usecase + +import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import javax.inject.Inject + +class ClearTokensUseCase @Inject constructor( + private val repository: AuthRepository +) { + suspend operator fun invoke(): Result = repository.clearTokens() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/GetStoredUserUseCase.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/GetStoredUserUseCase.kt new file mode 100644 index 0000000..9401624 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/GetStoredUserUseCase.kt @@ -0,0 +1,14 @@ +package com.sampoom.android.feature.auth.domain.usecase + +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.feature.auth.domain.model.User +import javax.inject.Inject + +class GetStoredUserUseCase @Inject constructor( + private val preferences: AuthPreferences +) { + suspend operator fun invoke(): User? = preferences.getStoredUser() +} + + + diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/GetVendorUseCase.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/GetVendorUseCase.kt new file mode 100644 index 0000000..e41b1f1 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/GetVendorUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.auth.domain.usecase + +import com.sampoom.android.feature.auth.domain.model.VendorList +import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import javax.inject.Inject + +class GetVendorUseCase @Inject constructor( + private val repository: AuthRepository +) { + suspend operator fun invoke(): Result = repository.getVendorList() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/LoginUseCase.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/LoginUseCase.kt new file mode 100644 index 0000000..4c47220 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/LoginUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.auth.domain.usecase + +import com.sampoom.android.feature.auth.domain.model.User +import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import javax.inject.Inject + +class LoginUseCase @Inject constructor( + private val repository: AuthRepository +) { + suspend operator fun invoke(email: String, password: String): Result = repository.signIn(email, password) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignOutUseCase.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignOutUseCase.kt new file mode 100644 index 0000000..026cef0 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignOutUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.auth.domain.usecase + +import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import javax.inject.Inject + +class SignOutUseCase @Inject constructor( + private val repository: AuthRepository +) { + suspend operator fun invoke(): Result = repository.signOut() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignUpUseCase.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignUpUseCase.kt new file mode 100644 index 0000000..9e5ec23 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignUpUseCase.kt @@ -0,0 +1,25 @@ +package com.sampoom.android.feature.auth.domain.usecase + +import com.sampoom.android.feature.auth.domain.model.User +import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import javax.inject.Inject + +class SignUpUseCase @Inject constructor( + private val repository: AuthRepository +) { + suspend operator fun invoke( + email: String, + password: String, + workspace: String, + branch: String, + userName: String, + position: String + ): Result = repository.signUp( + email = email, + password = password, + workspace = workspace, + branch = branch, + userName = userName, + position = position + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/AuthViewModel.kt b/app/src/main/java/com/sampoom/android/feature/auth/ui/AuthViewModel.kt new file mode 100644 index 0000000..efb1e6a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/AuthViewModel.kt @@ -0,0 +1,58 @@ +package com.sampoom.android.feature.auth.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.feature.auth.domain.usecase.CheckLoginStateUseCase +import com.sampoom.android.feature.auth.domain.usecase.ClearTokensUseCase +import com.sampoom.android.feature.auth.domain.usecase.SignOutUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val checkLoginStateUseCase: CheckLoginStateUseCase, + private val signOutUseCase: SignOutUseCase, + private val clearTokensUseCase: ClearTokensUseCase +) : ViewModel() { + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _logoutEvent = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + val logoutEvent: SharedFlow = _logoutEvent.asSharedFlow() + + init { + viewModelScope.launch { + _isLoggedIn.value = checkLoginStateUseCase() + _isLoading.value = false + } + } + + fun updateLoginState() = viewModelScope.launch { + _isLoggedIn.value = checkLoginStateUseCase() + } + + fun signOut() = viewModelScope.launch { + signOutUseCase() + _isLoggedIn.value = false + _logoutEvent.emit(Unit) + } + + fun handleTokenExpired() = viewModelScope.launch { + clearTokensUseCase() + _isLoggedIn.value = false + _logoutEvent.emit(Unit) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginScreen.kt b/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginScreen.kt new file mode 100644 index 0000000..4be3eb4 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginScreen.kt @@ -0,0 +1,155 @@ +package com.sampoom.android.feature.auth.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +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.CommonButton +import com.sampoom.android.core.ui.component.CommonTextField +import com.sampoom.android.core.ui.theme.Main500 + +@Composable +fun LoginScreen( + onSuccess: () -> Unit, + onNavigateSignUp: () -> Unit, + viewModel: LoginViewModel = hiltViewModel() +) { + val emailLabel = stringResource(R.string.login_title_email) + val passwordLabel = stringResource(R.string.login_title_password) + val errorLabel = stringResource(R.string.common_error) + + LaunchedEffect(emailLabel, passwordLabel, errorLabel) { + viewModel.bindLabel(emailLabel, passwordLabel, errorLabel) + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.success) { + if (uiState.success) onSuccess() + } + + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets + ) { innerPadding -> + val focusManager = LocalFocusManager.current + Box( // 터치 감지용 컨테이너 + modifier = Modifier + .fillMaxSize() + .clickable( // 빈 공간 터치 시 포커스 해제 + indication = null, // 터치 ripple 제거 + interactionSource = remember { MutableInteractionSource() } + ) { + focusManager.clearFocus() + } + ) { + Column( + Modifier + .fillMaxSize() + .imePadding() + .padding(innerPadding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.square_logo), + contentDescription = stringResource(R.string.app_name) + ) + Spacer(Modifier.height(48.dp)) + CommonTextField( + modifier = Modifier.fillMaxWidth(), + value = uiState.email, + onValueChange = { viewModel.onEvent(LoginUiEvent.EmailChanged(it)) }, + placeholder = stringResource(R.string.login_placeholder_email), + isError = uiState.emailError != null, + errorMessage = uiState.emailError + ) + Spacer(Modifier.height(8.dp)) + CommonTextField( + modifier = Modifier.fillMaxWidth(), + value = uiState.password, + onValueChange = { viewModel.onEvent(LoginUiEvent.PasswordChanged(it)) }, + placeholder = stringResource(R.string.login_placeholder_password), + isPassword = true, + isError = uiState.passwordError != null, + errorMessage = uiState.passwordError + ) + Spacer(Modifier.height(48.dp)) + CommonButton( + onClick = { viewModel.onEvent(LoginUiEvent.Submit) }, + enabled = uiState.isValid && !uiState.loading, + modifier = Modifier.fillMaxWidth() + ) { + Text( + if (uiState.loading) stringResource(R.string.login_button_login_loading) + else stringResource(R.string.login_button_login) + ) + } + + + Spacer(Modifier.weight(1f)) + + val annotatedText = buildAnnotatedString { + append(stringResource(R.string.login_need_account)) + + // 클릭 가능한 회원가입 텍스트 + pushStringAnnotation(tag = "SIGNUP", annotation = "signup") + withStyle( + style = SpanStyle( + color = Main500, // 원하는 색상 + textDecoration = TextDecoration.Underline + ) + ) { + append(stringResource(R.string.login_signup)) + } + append(stringResource(R.string.login_do)) + pop() + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = annotatedText, + modifier = Modifier + .padding(vertical = 24.dp) + .clickable { onNavigateSignUp() }, + style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center), + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiEvent.kt new file mode 100644 index 0000000..ea2af9a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiEvent.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.auth.ui + +sealed interface LoginUiEvent { + data class EmailChanged(val email: String) : LoginUiEvent + data class PasswordChanged(val password: String) : LoginUiEvent + data object Submit: LoginUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiState.kt b/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiState.kt new file mode 100644 index 0000000..9fcb9ee --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiState.kt @@ -0,0 +1,19 @@ +package com.sampoom.android.feature.auth.ui + +data class LoginUiState( + val email: String = "", + val password: String = "", + + // Error message + val emailError: String? = null, + val passwordError: String? = null, + + val loading: Boolean = false, + val success: Boolean = false +) { + val isValid: Boolean + get() = email.isNotBlank() && + password.isNotBlank() && + emailError == null && + passwordError == null +} diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginViewModel.kt b/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginViewModel.kt new file mode 100644 index 0000000..9e06634 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginViewModel.kt @@ -0,0 +1,104 @@ +package com.sampoom.android.feature.auth.ui + +import android.app.Application +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.core.network.serverMessageOrNull +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.auth.domain.AuthValidator +import com.sampoom.android.feature.auth.domain.ValidationResult +import com.sampoom.android.feature.auth.domain.usecase.LoginUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +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 LoginViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + private val singIn: LoginUseCase, + private val application: Application +) : ViewModel() { + + private companion object { + private const val TAG = "LoginViewModel" + } + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState + + private var emailLabel: String = "" + private var passwordLabel: String = "" + private var errorLabel: String = "" + + fun bindLabel(email: String, password: String, error: String) { + emailLabel = email + passwordLabel = password + errorLabel = error + } + + fun onEvent(e: LoginUiEvent) = when (e) { + is LoginUiEvent.EmailChanged -> { + _uiState.value = _uiState.value.copy(email = e.email) + validateEmail() + } + + is LoginUiEvent.PasswordChanged -> { + _uiState.value = _uiState.value.copy(password = e.password) + validatePassword() + } + + LoginUiEvent.Submit -> submit() + } + + private fun validateEmail() { + val result = AuthValidator.validateNotEmpty(_uiState.value.email, emailLabel) + _uiState.value = _uiState.value.copy( + emailError = result.toErrorMessage() + ) + } + + private fun validatePassword() { + val result = AuthValidator.validateNotEmpty(_uiState.value.password, passwordLabel) + _uiState.value = _uiState.value.copy( + passwordError = result.toErrorMessage() + ) + } + + private fun ValidationResult.toErrorMessage(): String? { + return when (this) { + is ValidationResult.Error -> application.getString(messageResId) + is ValidationResult.ErrorWithArgs -> application.getString(messageResId, args) + ValidationResult.Success -> null + } + } + + private fun submit() = viewModelScope.launch { + validateEmail() + validatePassword() + + if (!_uiState.value.isValid) return@launch + + val s = _uiState.value + _uiState.update { it.copy(loading = true, success = false) } + singIn(s.email, s.password) + .onSuccess { + _uiState.update { + it.copy(loading = false, success = true) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy(loading = false, success = false) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpScreen.kt b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpScreen.kt new file mode 100644 index 0000000..9ead5b9 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpScreen.kt @@ -0,0 +1,293 @@ +package com.sampoom.android.feature.auth.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.sampoom.android.R +import com.sampoom.android.core.model.UserPosition +import com.sampoom.android.core.ui.component.CommonButton +import com.sampoom.android.core.ui.component.CommonTextField +import com.sampoom.android.core.util.positionToKorean + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignUpScreen( + onSuccess: () -> Unit, + onNavigateBack: () -> Unit = {}, + viewModel: SignUpViewModel = hiltViewModel() +) { + val nameLabel = stringResource(R.string.signup_title_name) + val branchLabel = stringResource(R.string.signup_title_branch) + val positionLabel = stringResource(R.string.signup_title_position) + val errorLabel = stringResource(R.string.common_error) + + val nameFocus = remember { FocusRequester() } + val branchFocus = remember { FocusRequester() } + val positionFocus = remember { FocusRequester() } + val emailFocus = remember { FocusRequester() } + val passwordFocus = remember { FocusRequester() } + val passwordCheckFocus = remember { FocusRequester() } + + LaunchedEffect(nameLabel, branchLabel, positionLabel, errorLabel) { + viewModel.bindLabels(nameLabel, branchLabel, positionLabel, errorLabel) + } + + val state by viewModel.state.collectAsState() + val labelTextSize = 16.sp + + LaunchedEffect(state.success) { + if (state.success) onSuccess() + } + + val positionItems = remember { UserPosition.entries } + var positionMenuExpanded by remember { mutableStateOf(false) } + var vendorMenuExpanded by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.nav_back) + ) + } + } + ) + }, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets, +// snackbarHost = { CommonSnackBarHost(snackBarHostState) } + ) { innerPadding -> + val focusManager = LocalFocusManager.current + Box( // 터치 감지용 컨테이너 + modifier = Modifier + .fillMaxSize() + .clickable( // 빈 공간 터치 시 포커스 해제 + indication = null, // 터치 ripple 제거 + interactionSource = remember { MutableInteractionSource() } + ) { + focusManager.clearFocus() + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + .padding(16.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.oneline_logo), + contentDescription = stringResource(R.string.app_name) + ) + Spacer(Modifier.height(48.dp)) + Text( + text = stringResource(R.string.signup_title_name), + fontSize = labelTextSize + ) + CommonTextField( + modifier = Modifier.fillMaxWidth().focusRequester(nameFocus), + value = state.name, + onValueChange = { viewModel.onEvent(SignUpUiEvent.NameChanged(it)) }, + placeholder = stringResource(R.string.signup_placeholder_name), + isError = state.nameError != null, + errorMessage = state.nameError, + imeAction = ImeAction.Next, + keyboardActions = KeyboardActions(onNext = { branchFocus.requestFocus() }) + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_branch), + fontSize = labelTextSize + ) + ExposedDropdownMenuBox( + expanded = vendorMenuExpanded, + onExpandedChange = { vendorMenuExpanded = it && !state.vendorsLoading } + ) { + CommonTextField( + modifier = Modifier + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = true + ) + .fillMaxWidth() + .focusRequester(branchFocus), + readOnly = true, + value = state.selectedVendor?.name ?: "", + onValueChange = {}, + placeholder = stringResource(R.string.signup_placeholder_branch), + isError = state.branchError != null, + errorMessage = state.branchError, + singleLine = true, + enabled = !state.vendorsLoading + ) + ExposedDropdownMenu( + expanded = vendorMenuExpanded, + onDismissRequest = { vendorMenuExpanded = false } + ) { + if (state.vendorsLoading) { + DropdownMenuItem( + text = { Text("로딩 중...") }, + onClick = {} + ) + } else { + state.vendors.forEach { vendor -> + DropdownMenuItem( + text = { Text(vendor.name) }, + onClick = { + viewModel.onEvent(SignUpUiEvent.VendorChanged(vendor)) + vendorMenuExpanded = false + positionFocus.requestFocus() + } + ) + } + } + } + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_position), + fontSize = labelTextSize + ) + ExposedDropdownMenuBox( + expanded = positionMenuExpanded, + onExpandedChange = { positionMenuExpanded = it } + ) { + CommonTextField( + modifier = Modifier + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = true + ) + .fillMaxWidth(), + readOnly = true, + value = state.position?.let { positionToKorean(it) } ?: "", + onValueChange = {}, + placeholder = stringResource(R.string.signup_placeholder_position), + isError = state.positionError != null, + errorMessage = state.positionError, + singleLine = true + ) + ExposedDropdownMenu( + expanded = positionMenuExpanded, + onDismissRequest = { positionMenuExpanded = false } + ) { + positionItems.forEach { role -> + DropdownMenuItem( + text = { Text(positionToKorean(role)) }, + onClick = { + viewModel.onEvent(SignUpUiEvent.PositionChanged(role)) + positionMenuExpanded = false + emailFocus.requestFocus() + } + ) + } + } + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_email), + fontSize = labelTextSize + ) + CommonTextField( + modifier = Modifier.fillMaxWidth().focusRequester(emailFocus), + value = state.email, + onValueChange = { viewModel.onEvent(SignUpUiEvent.EmailChanged(it)) }, + placeholder = stringResource(R.string.signup_placeholder_email), + isError = state.emailError != null, + errorMessage = state.emailError, + imeAction = ImeAction.Next, + keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }) + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_password), + fontSize = labelTextSize + ) + CommonTextField( + modifier = Modifier.fillMaxWidth().focusRequester(passwordFocus), + value = state.password, + onValueChange = { viewModel.onEvent(SignUpUiEvent.PasswordChanged(it)) }, + placeholder = stringResource(R.string.signup_placeholder_password), + isPassword = true, + isError = state.passwordError != null, + errorMessage = state.passwordError, + imeAction = ImeAction.Next, + keyboardActions = KeyboardActions(onNext = { passwordCheckFocus.requestFocus() }) + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_password_check), + fontSize = labelTextSize + ) + CommonTextField( + modifier = Modifier.fillMaxWidth().focusRequester(passwordCheckFocus) , + value = state.passwordCheck, + onValueChange = { viewModel.onEvent(SignUpUiEvent.PasswordCheckChanged(it)) }, + placeholder = stringResource(R.string.signup_placeholder_password_check), + isPassword = true, + isError = state.passwordCheckError != null, + errorMessage = state.passwordCheckError, + imeAction = ImeAction.Done, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + Spacer(Modifier.height(48.dp)) + CommonButton( + onClick = { viewModel.onEvent(SignUpUiEvent.Submit) }, + enabled = state.isValid && !state.loading, + modifier = Modifier.fillMaxWidth().navigationBarsPadding(), + ) { + Text( + if (state.loading) stringResource(R.string.signup_button_signup_loading) + else stringResource(R.string.signup_button_signup) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiEvent.kt new file mode 100644 index 0000000..90bfe1d --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiEvent.kt @@ -0,0 +1,15 @@ +package com.sampoom.android.feature.auth.ui + +import com.sampoom.android.core.model.UserPosition +import com.sampoom.android.feature.auth.domain.model.Vendor + +sealed interface SignUpUiEvent { + data class NameChanged(val name: String) : SignUpUiEvent +// data class BranchChanged(val branch: String) : SignUpUiEvent + data class VendorChanged(val vendor: Vendor) : SignUpUiEvent + data class PositionChanged(val position: UserPosition) : SignUpUiEvent + data class EmailChanged(val email: String) : SignUpUiEvent + data class PasswordChanged(val password: String) : SignUpUiEvent + data class PasswordCheckChanged(val passwordCheck: String) : SignUpUiEvent + data object Submit: SignUpUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiState.kt b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiState.kt new file mode 100644 index 0000000..25a1814 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiState.kt @@ -0,0 +1,44 @@ +package com.sampoom.android.feature.auth.ui + +import com.sampoom.android.core.model.UserPosition +import com.sampoom.android.feature.auth.domain.model.Vendor + +data class SignUpUiState( + val name: String = "", + val workspace: String = "AGENCY", + val branch: String = "", + val position: UserPosition? = null, + val email: String = "", + val password: String = "", + val passwordCheck: String = "", + + // Vendor + val vendors: List = emptyList(), + val selectedVendor: Vendor? = null, + val vendorsLoading: Boolean = false, + + // Error message + val nameError: String? = null, + val branchError: String? = null, + val positionError: String? = null, + val emailError: String? = null, + val passwordError: String? = null, + val passwordCheckError: String? = null, + + val loading: Boolean = false, + val success: Boolean = false +) { + val isValid: Boolean + get() = name.isNotBlank() && + branch.isNotBlank() && + position != null && + email.isNotBlank() && + password.isNotBlank() && + passwordCheck.isNotBlank() && + nameError == null && + branchError == null && + positionError == null && + emailError == null && + passwordError == null && + passwordCheckError == null +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpViewModel.kt b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpViewModel.kt new file mode 100644 index 0000000..a448e7b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpViewModel.kt @@ -0,0 +1,206 @@ +package com.sampoom.android.feature.auth.ui + +import android.app.Application +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.R +import com.sampoom.android.core.network.serverMessageOrNull +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.auth.domain.AuthValidator +import com.sampoom.android.feature.auth.domain.AuthValidator.validateEmail +import com.sampoom.android.feature.auth.domain.AuthValidator.validatePassword +import com.sampoom.android.feature.auth.domain.AuthValidator.validatePasswordCheck +import com.sampoom.android.feature.auth.domain.ValidationResult +import com.sampoom.android.feature.auth.domain.usecase.GetVendorUseCase +import com.sampoom.android.feature.auth.domain.usecase.SignUpUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +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 SignUpViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + private val singUp: SignUpUseCase, + private val getVendorUseCase: GetVendorUseCase, + private val application: Application +) : ViewModel() { + + private companion object { + private const val TAG = "SignUpViewModel" + } + + private val _state = MutableStateFlow(SignUpUiState()) + val state: StateFlow = _state + + private var nameLabel: String = "" + private var branchLabel: String = "" + private var positionLabel: String = "" + private var errorLabel: String = "" + + init { + loadVendors() + } + + fun bindLabels(name: String, branch: String, position: String, error: String) { + nameLabel = name + branchLabel = branch + positionLabel = position + errorLabel = error + } + + fun onEvent(e: SignUpUiEvent) = when (e) { + is SignUpUiEvent.NameChanged -> { + _state.value = _state.value.copy(name = e.name) + validateName() + } +// is SignUpUiEvent.BranchChanged -> { +// _state.value = _state.value.copy(branch = e.branch) +// validateBranch() +// } + is SignUpUiEvent.VendorChanged -> { + _state.value = _state.value.copy( + selectedVendor = e.vendor, + branch = e.vendor.name + ) + validateBranch() + } + is SignUpUiEvent.PositionChanged -> { + _state.value = _state.value.copy(position = e.position) + validatePosition() + } + is SignUpUiEvent.EmailChanged -> { + _state.value = _state.value.copy(email = e.email) + validateEmail() + } + is SignUpUiEvent.PasswordChanged -> { + _state.value = _state.value.copy(password = e.password) + validatePassword() + if (_state.value.passwordCheck.isNotBlank()) { + validatePasswordCheck() + } else { + + } + } + is SignUpUiEvent.PasswordCheckChanged -> { + _state.value = _state.value.copy(passwordCheck = e.passwordCheck) + validatePasswordCheck() + } + SignUpUiEvent.Submit -> submit() + } + + private fun validateName() { + val result = AuthValidator.validateNotEmpty(_state.value.name, nameLabel) + _state.value = _state.value.copy( + nameError = result.toErrorMessage() + ) + } + + private fun validateBranch() { + val result = AuthValidator.validateNotEmpty(_state.value.branch, branchLabel) + _state.value = _state.value.copy( + branchError = result.toErrorMessage() + ) + } + + private fun validatePosition() { + val result = if (_state.value.position == null) { + ValidationResult.Error(messageResId = R.string.common_required_field) + } else { + ValidationResult.Success + } + _state.value = _state.value.copy( + positionError = result.toErrorMessage() + ) + } + + private fun validateEmail() { + val result = AuthValidator.validateEmail(_state.value.email) + _state.value = _state.value.copy( + emailError = result.toErrorMessage() + ) + } + + private fun validatePassword() { + val result = AuthValidator.validatePassword(_state.value.password) + _state.value = _state.value.copy( + passwordError = result.toErrorMessage() + ) + } + + private fun validatePasswordCheck() { + val result = AuthValidator.validatePasswordCheck(_state.value.password, _state.value.passwordCheck) + _state.value = _state.value.copy( + passwordCheckError = result.toErrorMessage() + ) + } + + private fun ValidationResult.toErrorMessage(): String? { + return when (this) { + is ValidationResult.Error -> application.getString(messageResId) + is ValidationResult.ErrorWithArgs -> application.getString(messageResId, args) + ValidationResult.Success -> null + } + } + + private fun submit() = viewModelScope.launch { + validateName() + validateBranch() + validatePosition() + validateEmail() + validatePassword() + validatePasswordCheck() + + if (!_state.value.isValid) return@launch + + val s = _state.value + _state.update { it.copy(loading = true) } + singUp( + email = s.email, + password = s.password, + workspace = s.workspace, + branch = s.branch, + userName = s.name, + position = s.position!!.name + ) + .onSuccess { + _state.update { + it.copy(loading = false, success = true) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _state.update { + it.copy(loading = false, ) + } + } + Log.d(TAG, "submit: ${_state.value}") + } + + private fun loadVendors() { + viewModelScope.launch { + _state.update { it.copy(vendorsLoading = true) } + getVendorUseCase() + .onSuccess { vendorList -> + _state.update { + it.copy( + vendors = vendorList.items, + vendorsLoading = false + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + _state.update { it.copy(vendorsLoading = false) } + } + } + } +} diff --git a/app/src/main/java/com/sampoom/android/feature/cart/data/mapper/CartMappers.kt b/app/src/main/java/com/sampoom/android/feature/cart/data/mapper/CartMappers.kt new file mode 100644 index 0000000..9f59d29 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/data/mapper/CartMappers.kt @@ -0,0 +1,12 @@ +package com.sampoom.android.feature.cart.data.mapper + +import com.sampoom.android.feature.cart.data.remote.dto.CartDto +import com.sampoom.android.feature.cart.data.remote.dto.CartGroupDto +import com.sampoom.android.feature.cart.data.remote.dto.CartPartDto +import com.sampoom.android.feature.cart.domain.model.Cart +import com.sampoom.android.feature.cart.domain.model.CartGroup +import com.sampoom.android.feature.cart.domain.model.CartPart + +fun CartDto.toModel(): Cart = Cart(categoryId, categoryName, groups.map { it.toModel() }) +fun CartGroupDto.toModel(): CartGroup = CartGroup(groupId, groupName, parts.map { it.toModel() }) +fun CartPartDto.toModel(): CartPart = CartPart(cartItemId, partId, code, name, quantity, standardCost) \ 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 new file mode 100644 index 0000000..92dc7eb --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/data/remote/api/CartApi.kt @@ -0,0 +1,45 @@ +package com.sampoom.android.feature.cart.data.remote.api + +import com.sampoom.android.core.model.ApiResponse +import com.sampoom.android.core.model.ApiSuccessResponse +import com.sampoom.android.feature.cart.data.remote.dto.AddCartRequestDto +import com.sampoom.android.feature.cart.data.remote.dto.CartDto +import com.sampoom.android.feature.cart.data.remote.dto.UpdateCartRequestDto +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path + +interface CartApi { + // 장바구니 목록 조회 + @GET("agency/{agencyId}/cart") + suspend fun getCartList(@Path("agencyId") agencyId: Long): ApiResponse> + + // 장바구니에 부품 추가 + @POST("agency/{agencyId}/cart") + suspend fun addCart( + @Path("agencyId") agencyId: Long, + @Body body: AddCartRequestDto + ): ApiSuccessResponse + + // 장바구니 항목 삭제 + @DELETE("agency/{agencyId}/cart/{cartItemId}") + suspend fun deleteCart( + @Path("agencyId") agencyId: Long, + @Path("cartItemId") cartItemId: Long + ): ApiSuccessResponse + + // 장바구니 수량 변경 + @PUT("agency/{agencyId}/cart/{cartItemId}") + suspend fun updateCart( + @Path("agencyId") agencyId: Long, + @Path("cartItemId") cartItemId: Long, + @Body body: UpdateCartRequestDto + ): ApiSuccessResponse + + // 장바구니 전체 비우기 + @DELETE("agency/{agencyId}/cart/clear") + suspend fun deleteAllCart(@Path("agencyId") agencyId: Long): ApiSuccessResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/data/remote/dto/AddCartRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/cart/data/remote/dto/AddCartRequestDto.kt new file mode 100644 index 0000000..a47af11 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/data/remote/dto/AddCartRequestDto.kt @@ -0,0 +1,6 @@ +package com.sampoom.android.feature.cart.data.remote.dto + +data class AddCartRequestDto( + val partId: Long, + val quantity: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/cart/data/remote/dto/CartDto.kt b/app/src/main/java/com/sampoom/android/feature/cart/data/remote/dto/CartDto.kt new file mode 100644 index 0000000..d144195 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/data/remote/dto/CartDto.kt @@ -0,0 +1,22 @@ +package com.sampoom.android.feature.cart.data.remote.dto + +data class CartDto( + val categoryId: Long, + val categoryName: String, + val groups: List +) + +data class CartGroupDto( + val groupId: Long, + val groupName: String, + val parts: List +) + +data class CartPartDto( + val cartItemId: Long, + val partId: Long, + val code: String, + val name: String, + val quantity: Long, + val standardCost: Long +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/data/remote/dto/UpdateCartRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/cart/data/remote/dto/UpdateCartRequestDto.kt new file mode 100644 index 0000000..87d5841 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/data/remote/dto/UpdateCartRequestDto.kt @@ -0,0 +1,5 @@ +package com.sampoom.android.feature.cart.data.remote.dto + +data class UpdateCartRequestDto( + val quantity: Long +) 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 new file mode 100644 index 0000000..a8554d4 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/data/repository/CartRepositoryImpl.kt @@ -0,0 +1,62 @@ +package com.sampoom.android.feature.cart.data.repository + +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.feature.cart.data.mapper.toModel +import com.sampoom.android.feature.cart.data.remote.api.CartApi +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 javax.inject.Inject + +class CartRepositoryImpl @Inject constructor( + private val api: CartApi, + private val authPreferences: AuthPreferences +) : CartRepository { + override suspend fun getCartList(): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.getCartList(agencyId) + val cartItems = dto.data.map { it.toModel() } + CartList(items = cartItems) + } + } + + override suspend fun addCart( + partId: Long, + quantity: Long + ): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.addCart(agencyId = agencyId, body = AddCartRequestDto(partId, quantity)) + if (!dto.success) throw Exception(dto.message) + } + } + + override suspend fun deleteCart(cartItemId: Long): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.deleteCart(agencyId = agencyId, cartItemId = cartItemId) + if (!dto.success) throw Exception(dto.message) + } + } + + override suspend fun deleteAllCart(): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.deleteAllCart(agencyId) + if (!dto.success) throw Exception(dto.message) + } + } + + override suspend fun updateCartQuantity( + cartItemId: Long, + quantity: Long + ): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.updateCart(agencyId = agencyId, cartItemId = cartItemId, body = UpdateCartRequestDto(quantity)) + if (!dto.success) throw Exception(dto.message) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/di/CartModules.kt b/app/src/main/java/com/sampoom/android/feature/cart/di/CartModules.kt new file mode 100644 index 0000000..18dc1bd --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/di/CartModules.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.feature.cart.di + +import com.sampoom.android.feature.cart.data.remote.api.CartApi +import com.sampoom.android.feature.cart.data.repository.CartRepositoryImpl +import com.sampoom.android.feature.cart.domain.repository.CartRepository +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 CartBinModule { + @Binds @Singleton + abstract fun bindCartRepository(impl: CartRepositoryImpl): CartRepository +} + +@Module +@InstallIn(SingletonComponent::class) +object CartModule { + @Provides @Singleton + fun provideCartApi(retrofit: Retrofit): CartApi = retrofit.create(CartApi::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/domain/model/Cart.kt b/app/src/main/java/com/sampoom/android/feature/cart/domain/model/Cart.kt new file mode 100644 index 0000000..41a5a32 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/domain/model/Cart.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.feature.cart.domain.model + +data class Cart( + val categoryId: Long, + val categoryName: String, + val groups: List +) + +data class CartGroup( + val groupId: Long, + val groupName: String, + val parts: List +) + +data class CartPart( + val cartItemId: Long, + val partId: Long, + val code: String, + val name: String, + val quantity: Long, + val standardCost: Long +) + +// 품목 합계(단가 x 수량) +val CartPart.subtotal: Long + get() = standardCost * quantity \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/domain/model/CartList.kt b/app/src/main/java/com/sampoom/android/feature/cart/domain/model/CartList.kt new file mode 100644 index 0000000..ac270e4 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/domain/model/CartList.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.cart.domain.model + +data class CartList( + val items: List, + val totalCount: Int = items.size, + val isEmpty: Boolean = items.isEmpty() +) { + companion object Companion { + fun empty() = CartList(emptyList()) + } +} diff --git a/app/src/main/java/com/sampoom/android/feature/cart/domain/repository/CartRepository.kt b/app/src/main/java/com/sampoom/android/feature/cart/domain/repository/CartRepository.kt new file mode 100644 index 0000000..d6b9df5 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/domain/repository/CartRepository.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.cart.domain.repository + +import com.sampoom.android.feature.cart.domain.model.CartList + +interface CartRepository { + suspend fun getCartList(): Result + suspend fun addCart(partId: Long, quantity: Long): Result + suspend fun deleteCart(cartItemId: Long): Result + suspend fun deleteAllCart(): Result + suspend fun updateCartQuantity(cartItemId: Long, quantity: Long): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/AddCartUseCase.kt b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/AddCartUseCase.kt new file mode 100644 index 0000000..592e4cb --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/AddCartUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.cart.domain.usecase + +import com.sampoom.android.feature.cart.domain.repository.CartRepository +import javax.inject.Inject + +class AddCartUseCase @Inject constructor( + private val repository: CartRepository +) { + suspend operator fun invoke(partId: Long, quantity: Long): Result = repository.addCart(partId, quantity) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/DeleteAllCartUseCase.kt b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/DeleteAllCartUseCase.kt new file mode 100644 index 0000000..db5089e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/DeleteAllCartUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.cart.domain.usecase + +import com.sampoom.android.feature.cart.domain.repository.CartRepository +import javax.inject.Inject + +class DeleteAllCartUseCase @Inject constructor( + private val repository: CartRepository +) { + suspend operator fun invoke(): Result = repository.deleteAllCart() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/DeleteCartUseCase.kt b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/DeleteCartUseCase.kt new file mode 100644 index 0000000..ac54c16 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/DeleteCartUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.cart.domain.usecase + +import com.sampoom.android.feature.cart.domain.repository.CartRepository +import javax.inject.Inject + +class DeleteCartUseCase @Inject constructor( + private val repository: CartRepository +) { + suspend operator fun invoke(cartItemId: Long): Result = repository.deleteCart(cartItemId) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/GetCartUseCase.kt b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/GetCartUseCase.kt new file mode 100644 index 0000000..129615b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/GetCartUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.cart.domain.usecase + +import com.sampoom.android.feature.cart.domain.model.CartList +import com.sampoom.android.feature.cart.domain.repository.CartRepository +import javax.inject.Inject + +class GetCartUseCase @Inject constructor( + private val repository: CartRepository +) { + suspend operator fun invoke(): Result = repository.getCartList() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/UpdateCartQuantityUseCase.kt b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/UpdateCartQuantityUseCase.kt new file mode 100644 index 0000000..e6169b6 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/domain/usecase/UpdateCartQuantityUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.cart.domain.usecase + +import com.sampoom.android.feature.cart.domain.repository.CartRepository +import javax.inject.Inject + +class UpdateCartQuantityUseCase @Inject constructor( + private val repository: CartRepository +) { + suspend operator fun invoke(cartItemId: Long, quantity: Long): Result = repository.updateCartQuantity(cartItemId, quantity) +} \ 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 new file mode 100644 index 0000000..d17be0e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListScreen.kt @@ -0,0 +1,408 @@ +package com.sampoom.android.feature.cart.ui + +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.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +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 +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.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.ButtonSize +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.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.core.util.formatWon +import com.sampoom.android.feature.cart.domain.model.CartPart +import com.sampoom.android.feature.cart.domain.model.subtotal +import com.sampoom.android.feature.order.ui.OrderResultBottomSheet + +@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) } + + LaunchedEffect(errorLabel) { + viewModel.bindLabel(errorLabel) + viewModel.onEvent(CartListUiEvent.LoadCartList) + } + + uiState.processedOrder?.let { order -> + OrderResultBottomSheet( + order = order, + onDismiss = { viewModel.onEvent(CartListUiEvent.DismissOrderResult) } + ) + } + + PullToRefreshBox( + isRefreshing = false, + 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 -> { + 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) + ) + } + } + + uiState.cartList.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + 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)) } + } + + CommonButton( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomEnd) + .padding(16.dp) + .padding(end = 72.dp), + variant = ButtonVariant.Primary, + size = ButtonSize.Large, + onClick = { showConfirmDialog = true } + ) { Text("${formatWon(uiState.totalCost)} ${stringResource(R.string.cart_order_parts)}") } + } + } + } + } + } + + if (showEmptyCartDialog) { + AlertDialog( + onDismissRequest = { showEmptyCartDialog = false }, + text = { Text(stringResource(R.string.cart_dialog_empty_text)) }, + confirmButton = { + TextButton( + onClick = { + showEmptyCartDialog = false + viewModel.onEvent(CartListUiEvent.DeleteAllCart) + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showEmptyCartDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } + + if (showConfirmDialog) { + AlertDialog( + onDismissRequest = { showConfirmDialog = false }, + text = { Text(stringResource(R.string.cart_dialog_order_text)) }, + confirmButton = { + TextButton( + onClick = { + showConfirmDialog = false + viewModel.onEvent(CartListUiEvent.ProcessOrder) + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showConfirmDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } +} + +@Composable +private fun CartSection( + categoryName: String, + groupName: String, + parts: List, + isUpdating: Boolean, + isDeleting: Boolean, + onEvent: (CartListUiEvent) -> Unit +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "$categoryName > $groupName", + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + + parts.forEach { part -> + CartPartItem( + part = part, + isUpdating = isUpdating, + isDeleting = isDeleting, + onEvent = onEvent + ) + } + } +} + +@Composable +private fun CartPartItem( + part: CartPart, + isUpdating: Boolean, + isDeleting: Boolean, + onEvent: (CartListUiEvent) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundCardColor()) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1F) + ) { + Text( + text = part.name, + style = MaterialTheme.typography.titleMedium, + color = textColor(), + modifier = Modifier.fillMaxWidth() + ) + Text( + text = part.code, + style = MaterialTheme.typography.bodySmall, + color = textSecondaryColor(), + modifier = Modifier.fillMaxWidth() + ) + } + + IconButton( + onClick = { + onEvent(CartListUiEvent.DeleteCart(part.cartItemId)) + }, + enabled = !isDeleting + ) { + Icon( + painter = painterResource(R.drawable.delete), + contentDescription = stringResource(R.string.common_delete), + tint = FailRed + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(R.string.part_title_quantity), Modifier.weight(1F)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CommonButton( + variant = ButtonVariant.Neutral, + size = ButtonSize.Large, + onClick = { + if (part.quantity > 1) onEvent( + CartListUiEvent.UpdateQuantity( + part.cartItemId, + part.quantity - 1 + ) + ) + }, + enabled = !isUpdating && part.quantity > 1 + ) { + Text( + text = stringResource(R.string.part_minus), + style = MaterialTheme.typography.titleLarge + ) + } + + Text( + text = part.quantity.toString(), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 24.dp) + ) + CommonButton( + variant = ButtonVariant.Neutral, + size = ButtonSize.Large, + onClick = { + onEvent( + CartListUiEvent.UpdateQuantity( + part.cartItemId, + part.quantity + 1 + ) + ) + }, + enabled = !isUpdating + ) { + Text( + text = stringResource(R.string.part_plus), + style = MaterialTheme.typography.titleLarge + ) + } + } + } + + val subtotal = part.subtotal + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatWon(subtotal), + style = MaterialTheme.typography.titleLarge + ) + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..c240749 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiEvent.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.cart.ui + +sealed interface CartListUiEvent { + object LoadCartList : CartListUiEvent + object RetryCartList : CartListUiEvent + object ProcessOrder : CartListUiEvent + data class UpdateQuantity(val cartItemId: Long, val quantity: Long) : CartListUiEvent + data class DeleteCart(val cartItemId: Long) : CartListUiEvent + object DeleteAllCart : 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 new file mode 100644 index 0000000..ade7798 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListUiState.kt @@ -0,0 +1,27 @@ +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(), + val cartLoading: Boolean = false, + val cartError: String? = null, + val selectedCart: Cart? = null, + val isUpdating: Boolean = false, + val updateError: String? = null, + val isDeleting: Boolean = false, + val deleteError: String? = null, + val isProcessing: Boolean = false, + val processError: String? = null, + val processedOrder: Order? = null +) { + val totalCost: Long + get() = cartList.sumOf { category -> + category.groups.sumOf { group -> + group.parts.sumOf { part -> + part.standardCost * part.quantity + } + } + } +} 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 new file mode 100644 index 0000000..4c3878b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/cart/ui/CartListViewModel.kt @@ -0,0 +1,239 @@ +package com.sampoom.android.feature.cart.ui + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.core.network.serverMessageOrNull +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.cart.domain.model.CartList +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CartListViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + private val getCartListUseCase: GetCartUseCase, + private val updateCartQuantityUseCase: UpdateCartQuantityUseCase, + private val deleteCartUseCase: DeleteCartUseCase, + private val deleteAllCartUseCase: DeleteAllCartUseCase, + private val createOrderUseCase: CreateOrderUseCase +) : ViewModel() { + + private companion object { + private const val TAG = "CartListViewModel" + } + + private val _uiState = MutableStateFlow(CartListUiState()) + val uiState: StateFlow = _uiState + + private var errorLabel: String = "" + + fun bindLabel(error: String) { + errorLabel = error + } + + init { + loadCartList() + } + + fun onEvent(event: CartListUiEvent) { + when (event) { + is CartListUiEvent.LoadCartList -> loadCartList() + is CartListUiEvent.RetryCartList -> loadCartList() + is CartListUiEvent.ProcessOrder -> processOrder() + is CartListUiEvent.UpdateQuantity -> updateQuantity(event.cartItemId, event.quantity) + is CartListUiEvent.DeleteCart -> deleteCart(event.cartItemId) + is CartListUiEvent.DeleteAllCart -> deleteAllCart() + is CartListUiEvent.DismissOrderResult -> _uiState.update { it.copy(processedOrder = null) } + } + } + + private fun loadCartList() { + viewModelScope.launch { + _uiState.update { it.copy(cartLoading = true, cartError = null) } + + getCartListUseCase() + .onSuccess { cartList -> + _uiState.update { + it.copy( + cartList = cartList.items, + cartLoading = false, + cartError = null + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + cartLoading = false, + cartError = error + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun processOrder() { + viewModelScope.launch { + _uiState.update { it.copy(isProcessing = true, processError = null) } + + val cartList = CartList(items = _uiState.value.cartList) + createOrderUseCase(cartList) + .onSuccess { order -> + _uiState.update { it.copy(isProcessing = false, processedOrder = order) } + deleteAllCart() + loadCartList() + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + isProcessing = false, + processError = error + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun updateQuantity(cartItemId: Long, newQuantity: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isUpdating = true, updateError = null) } + + updateCartQuantityUseCase(cartItemId, newQuantity) + .onSuccess { + _uiState.update { it.copy(isUpdating = false) } + updateLocalQuantity(cartItemId, newQuantity) + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + isUpdating = false, + updateError = error + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun updateLocalQuantity(cartItemId: Long, newQuantity: Long) { + _uiState.update { currentState -> + val updatedList = currentState.cartList.map { category -> + category.copy( + groups = category.groups.map { group -> + group.copy( + parts = group.parts.map { part -> + if (part.cartItemId == cartItemId) { + part.copy(quantity = newQuantity) + } else { + part + } + } + ) + } + ) + } + currentState.copy(cartList = updatedList) + } + } + + private fun deleteCart(cartItemId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isDeleting = true, deleteError = null) } + + deleteCartUseCase(cartItemId) + .onSuccess { + _uiState.update { it.copy(isDeleting = false) } + removeFromLocalList(cartItemId) + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + isDeleting = false, + deleteError = error + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun deleteAllCart() { + viewModelScope.launch { + _uiState.update { it.copy(isDeleting = true, deleteError = null) } + + deleteAllCartUseCase() + .onSuccess { + _uiState.update { it.copy(isDeleting = false) } + removeAllFromLocalList() + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + isDeleting = false, + deleteError = error + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun removeFromLocalList(cartItemId: Long) { + _uiState.update { currentState -> + val updatedList = currentState.cartList.map { category -> + category.copy( + groups = category.groups.map { group -> + group.copy( + parts = group.parts.filter { part -> + part.cartItemId != cartItemId + } + ) + }.filter { group -> + group.parts.isNotEmpty() + } + ) + }.filter { category -> + category.groups.isNotEmpty() + } + currentState.copy(cartList = updatedList) + } + } + + private fun removeAllFromLocalList() { + _uiState.update { currentState -> + currentState.copy(cartList = emptyList()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/data/mapper/DashboardMappers.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/data/mapper/DashboardMappers.kt new file mode 100644 index 0000000..830b38e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/data/mapper/DashboardMappers.kt @@ -0,0 +1,9 @@ +package com.sampoom.android.feature.dashboard.data.mapper + +import com.sampoom.android.feature.dashboard.data.remote.dto.DashboardResponseDto +import com.sampoom.android.feature.dashboard.data.remote.dto.WeeklySummaryResponseDto +import com.sampoom.android.feature.dashboard.domain.model.Dashboard +import com.sampoom.android.feature.dashboard.domain.model.WeeklySummary + +fun DashboardResponseDto.toModel(): Dashboard = Dashboard(totalParts, outOfStockParts, lowStockParts, totalQuantity) +fun WeeklySummaryResponseDto.toModel(): WeeklySummary = WeeklySummary(inStockParts, outStockParts, weekPeriod) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/data/remote/api/DashboardApi.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/data/remote/api/DashboardApi.kt new file mode 100644 index 0000000..fde49dc --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/data/remote/api/DashboardApi.kt @@ -0,0 +1,17 @@ +package com.sampoom.android.feature.dashboard.data.remote.api + +import com.sampoom.android.core.model.ApiResponse +import com.sampoom.android.feature.dashboard.data.remote.dto.DashboardResponseDto +import com.sampoom.android.feature.dashboard.data.remote.dto.WeeklySummaryResponseDto +import retrofit2.http.GET +import retrofit2.http.Path + +interface DashboardApi { + // 대시보드 조회 + @GET("agency/{agencyId}/dashboard") + suspend fun getDashboard(@Path("agencyId") agencyId: Long): ApiResponse + + // 주간 히스토리 조회 + @GET("agency/{agencyId}/weekly-summary") + suspend fun getWeeklySummary(@Path("agencyId") agencyId: Long): ApiResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/data/remote/dto/DashboardResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/data/remote/dto/DashboardResponseDto.kt new file mode 100644 index 0000000..54c0643 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/data/remote/dto/DashboardResponseDto.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.dashboard.data.remote.dto + +data class DashboardResponseDto( + val totalParts: Long, + val outOfStockParts: Long, + val lowStockParts: Long, + val totalQuantity: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/data/remote/dto/WeeklySummaryResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/data/remote/dto/WeeklySummaryResponseDto.kt new file mode 100644 index 0000000..2b4d4b9 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/data/remote/dto/WeeklySummaryResponseDto.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.dashboard.data.remote.dto + +data class WeeklySummaryResponseDto( + val inStockParts: Long, + val outStockParts: Long, + val weekPeriod: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/data/repository/DashboardRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/data/repository/DashboardRepositoryImpl.kt new file mode 100644 index 0000000..5f0f1d9 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/data/repository/DashboardRepositoryImpl.kt @@ -0,0 +1,30 @@ +package com.sampoom.android.feature.dashboard.data.repository + +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.feature.dashboard.data.mapper.toModel +import com.sampoom.android.feature.dashboard.data.remote.api.DashboardApi +import com.sampoom.android.feature.dashboard.domain.model.Dashboard +import com.sampoom.android.feature.dashboard.domain.model.WeeklySummary +import com.sampoom.android.feature.dashboard.domain.repository.DashboardRepository +import javax.inject.Inject + +class DashboardRepositoryImpl @Inject constructor( + private val api: DashboardApi, + private val authPreferences: AuthPreferences +) : DashboardRepository { + override suspend fun getDashboard(): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.getDashboard(agencyId) + dto.data.toModel() + } + } + + override suspend fun getWeeklySummary(): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.getWeeklySummary(agencyId) + dto.data.toModel() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/di/DashboardModules.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/di/DashboardModules.kt new file mode 100644 index 0000000..597d466 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/di/DashboardModules.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.feature.dashboard.di + +import com.sampoom.android.feature.dashboard.data.remote.api.DashboardApi +import com.sampoom.android.feature.dashboard.data.repository.DashboardRepositoryImpl +import com.sampoom.android.feature.dashboard.domain.repository.DashboardRepository +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 DashboardBinModule { + @Binds @Singleton + abstract fun bindDashboardRepository(impl: DashboardRepositoryImpl): DashboardRepository +} + +@Module +@InstallIn(SingletonComponent::class) +object DashboardModule { + @Provides @Singleton + fun provideDashboardApi(retrofit: Retrofit): DashboardApi = retrofit.create(DashboardApi::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/domain/model/Dashboard.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/model/Dashboard.kt new file mode 100644 index 0000000..12c75bd --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/model/Dashboard.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.dashboard.domain.model + +data class Dashboard( + val totalParts: Long, + val outOfStockParts: Long, + val lowStockParts: Long, + val totalQuantity: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/domain/model/WeeklySummary.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/model/WeeklySummary.kt new file mode 100644 index 0000000..e2b38ba --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/model/WeeklySummary.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.dashboard.domain.model + +data class WeeklySummary( + val inStockParts: Long, + val outStockParts: Long, + val weekPeriod: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/domain/repository/DashboardRepository.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/repository/DashboardRepository.kt new file mode 100644 index 0000000..f26b53a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/repository/DashboardRepository.kt @@ -0,0 +1,9 @@ +package com.sampoom.android.feature.dashboard.domain.repository + +import com.sampoom.android.feature.dashboard.domain.model.Dashboard +import com.sampoom.android.feature.dashboard.domain.model.WeeklySummary + +interface DashboardRepository { + suspend fun getDashboard(): Result + suspend fun getWeeklySummary(): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/domain/usecase/GetDashboardUseCase.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/usecase/GetDashboardUseCase.kt new file mode 100644 index 0000000..f19af0f --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/usecase/GetDashboardUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.dashboard.domain.usecase + +import com.sampoom.android.feature.dashboard.domain.model.Dashboard +import com.sampoom.android.feature.dashboard.domain.repository.DashboardRepository +import javax.inject.Inject + +class GetDashboardUseCase @Inject constructor( + private val repository: DashboardRepository +) { + suspend operator fun invoke(): Result = repository.getDashboard() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/domain/usecase/WeeklySummaryUseCase.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/usecase/WeeklySummaryUseCase.kt new file mode 100644 index 0000000..51620c6 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/domain/usecase/WeeklySummaryUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.dashboard.domain.usecase + +import com.sampoom.android.feature.dashboard.domain.model.WeeklySummary +import com.sampoom.android.feature.dashboard.domain.repository.DashboardRepository +import javax.inject.Inject + +class WeeklySummaryUseCase @Inject constructor( + private val repository: DashboardRepository +) { + suspend operator fun invoke(): Result = repository.getWeeklySummary() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardScreen.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardScreen.kt new file mode 100644 index 0000000..4971cc0 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardScreen.kt @@ -0,0 +1,519 @@ +package com.sampoom.android.feature.dashboard.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.sampoom.android.R +import com.sampoom.android.core.model.UserPosition +import com.sampoom.android.core.ui.component.EmptyContent +import com.sampoom.android.core.ui.component.ErrorContent +import com.sampoom.android.core.ui.theme.FailRed +import com.sampoom.android.core.ui.theme.Main500 +import com.sampoom.android.core.ui.theme.SuccessGreen +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.auth.domain.model.User +import com.sampoom.android.feature.dashboard.domain.model.Dashboard +import com.sampoom.android.feature.dashboard.domain.model.WeeklySummary +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.ui.OrderItem + +@Composable +fun DashboardScreen( + paddingValues: PaddingValues, + onSettingClick: () -> Unit, + onNavigateOrderDetail: (Order) -> Unit, + onNavigationOrder: () -> Unit, + viewModel: DashboardViewModel = hiltViewModel() +) { + val errorLabel = stringResource(R.string.common_error) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val user by viewModel.user.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() + val orderListPaged = viewModel.orderListPaged.collectAsLazyPagingItems() + val isManager = when (user?.position) { + UserPosition.STAFF, + UserPosition.SENIOR_STAFF, + UserPosition.ASSISTANT_MANAGER, + UserPosition.MANAGER, + UserPosition.DEPUTY_GENERAL_MANAGER, + UserPosition.GENERAL_MANAGER, + UserPosition.DIRECTOR, + UserPosition.VICE_PRESIDENT, + UserPosition.PRESIDENT, + UserPosition.CHAIRMAN -> true + + else -> false + } + + LaunchedEffect(errorLabel) { + viewModel.bindLabel(errorLabel) + } + + PullToRefreshBox( + isRefreshing = false, + onRefresh = { + viewModel.onEvent(DashboardUiEvent.LoadDashboard) + orderListPaged.refresh() + }, + state = pullRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = uiState.dashboardLoading, + 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 + ) { + Image( + modifier = Modifier + .padding(vertical = 16.dp) + .height(24.dp), + painter = painterResource(id = R.drawable.oneline_logo), + contentDescription = stringResource(R.string.app_name) + ) + + Row { + if (isManager) { + IconButton( + onClick = { } + ) { + Icon( + painter = painterResource(R.drawable.employee), + contentDescription = stringResource(R.string.nav_employee) + ) + } + } + + IconButton( + onClick = { onSettingClick() } + ) { + Icon( + painter = painterResource(R.drawable.settings), + contentDescription = stringResource(R.string.nav_setting) + ) + } + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { TitleSection(user) } + + item { ButtonSection(isManager, uiState.dashboard) } + + item { + OrderListSection( + orderListPaged = orderListPaged, + onNavigateOrderDetail = { order -> + onNavigateOrderDetail(order) + }, + onNavigationOrder = { + onNavigationOrder() + } + ) + } + + item { Spacer(Modifier.height(32.dp)) } + + item { + WeeklySummarySection(modifier = Modifier, weeklySummary = uiState.weeklySummary) + } + + item { Spacer(Modifier.height(100.dp)) } + } + } + } +} + +@Composable +fun TitleSection( + user: User? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = user?.branch ?: "", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = textColor() + ) + + val welcomeText = buildAnnotatedString { + append(stringResource(R.string.dashboard_title_hello)) + + pushStringAnnotation(tag = "NAME", annotation = "name") + withStyle(style = SpanStyle(color = Main500)) { + append(user?.userName ?: "") + } + append(stringResource(R.string.dashboard_title_hello_sir)) + pop() + } + + Text( + text = welcomeText, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = textColor() + ) + + Text( + text = stringResource(R.string.dashboard_title_description), + style = MaterialTheme.typography.bodyLarge, + color = textColor() + ) + } +} + +@Composable +fun ButtonSection( + isManager: Boolean, + dashboard: Dashboard? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (isManager) { + ButtonCard( + modifier = Modifier.border( + 1.dp, + Main500, + shape = RoundedCornerShape(corner = CornerSize(16.dp)) + ), + painter = painterResource(R.drawable.employee), + painterDescription = stringResource(R.string.dashboard_employee), + text = 45.toString(), // TODO : API 연동 + subText = stringResource(R.string.dashboard_employee), + onClick = { } + ) + } + + // 총 부품, 품절 부품 + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + ButtonCard( + modifier = Modifier.weight(1f), + painter = painterResource(R.drawable.car), + painterDescription = stringResource(R.string.dashboard_parts_all), + text = (dashboard?.totalParts ?: stringResource(R.string.common_slash)).toString(), + subText = stringResource(R.string.dashboard_parts_all), + onClick = { } + ) + + ButtonCard( + modifier = Modifier.weight(1f), + painter = painterResource(R.drawable.block), + painterDescription = stringResource(R.string.dashboard_parts_out_of_stock), + text = (dashboard?.outOfStockParts ?: stringResource(R.string.common_slash)).toString(), + subText = stringResource(R.string.dashboard_parts_out_of_stock), + onClick = { } + ) + } + + // 부족 부품, 보유 부품 + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + ButtonCard( + modifier = Modifier.weight(1f), + painter = painterResource(R.drawable.warning), + painterDescription = stringResource(R.string.dashboard_parts_low_stock), + text = (dashboard?.lowStockParts ?: stringResource(R.string.common_slash)).toString(), + subText = stringResource(R.string.dashboard_parts_low_stock), + onClick = { } + ) + + ButtonCard( + modifier = Modifier.weight(1f), + painter = painterResource(R.drawable.parts), + painterDescription = stringResource(R.string.dashboard_parts_on_hand), + text = (dashboard?.totalQuantity ?: stringResource(R.string.common_slash)).toString(), + subText = stringResource(R.string.dashboard_parts_on_hand), + onClick = { } + ) + } + } +} + +@Composable +fun ButtonCard( + modifier: Modifier, + painter: Painter, + painterDescription: String, + text: String, + subText: String, + onClick: () -> Unit +) { + Card( + modifier = modifier + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = backgroundCardColor() + ), + onClick = { onClick() }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + modifier = Modifier + .background(color = Main500, shape = CircleShape) + .padding(8.dp), + painter = painter, + tint = Color.White, + contentDescription = painterDescription, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = text, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = textColor() + ) + + Text( + text = subText, + style = MaterialTheme.typography.bodyMedium, + color = textSecondaryColor() + ) + } + } +} + +@Composable +fun OrderListSection( + orderListPaged: LazyPagingItems, + onNavigateOrderDetail: (Order) -> Unit, + onNavigationOrder: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.dashboard_order_recent_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = textColor() + ) + + IconButton( + onClick = { onNavigationOrder() } + ) { + Icon( + painter = painterResource(R.drawable.chevron_right), + contentDescription = stringResource(R.string.common_detail), + tint = textSecondaryColor() + ) + } + } + + when (orderListPaged.loadState.refresh) { + is LoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is LoadState.Error -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + ErrorContent( + onRetry = { orderListPaged.refresh() }, + modifier = Modifier.height(200.dp) + ) + } + } + + else -> { + if (orderListPaged.itemCount == 0) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + EmptyContent( + message = stringResource(R.string.order_empty_list), + modifier = Modifier.height(200.dp) + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // 처음 5개만 표시 + repeat(minOf(orderListPaged.itemCount, 5)) { index -> + val order = orderListPaged[index] + if (order != null) { + OrderItem( + order = order, + onClick = { onNavigateOrderDetail(order) } + ) + } + } + } + } + } + } + } +} + +@Composable +fun WeeklySummarySection( + modifier: Modifier, + weeklySummary: WeeklySummary? +) { + Card( + modifier = modifier + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = backgroundCardColor() + ), + onClick = { }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.dashboard_weekly_summary_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = textColor() + ) + + Row( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = (weeklySummary?.inStockParts ?: stringResource(R.string.common_slash)).toString(), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = SuccessGreen + ) + Text( + text = stringResource(R.string.dashboard_weekly_summary_in_stock), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Light, + color = textSecondaryColor() + ) + } + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = (weeklySummary?.outStockParts ?: stringResource(R.string.common_slash)).toString(), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = FailRed + ) + Text( + text = stringResource(R.string.dashboard_weekly_summary_out_stock), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Light, + color = textSecondaryColor() + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardUiEvent.kt new file mode 100644 index 0000000..dfe8294 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardUiEvent.kt @@ -0,0 +1,6 @@ +package com.sampoom.android.feature.dashboard.ui + +sealed interface DashboardUiEvent { + object LoadDashboard : DashboardUiEvent + object RetryDashboard : DashboardUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardUiState.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardUiState.kt new file mode 100644 index 0000000..4986b22 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardUiState.kt @@ -0,0 +1,15 @@ +package com.sampoom.android.feature.dashboard.ui + +import com.sampoom.android.feature.dashboard.domain.model.Dashboard +import com.sampoom.android.feature.dashboard.domain.model.WeeklySummary +import com.sampoom.android.feature.order.domain.model.Order + +data class DashboardUiState( + val orderList: List = emptyList(), + val dashboard: Dashboard? = null, + val weeklySummary: WeeklySummary? = null, + val dashboardLoading: Boolean = false, + val dashboardError: String? = null, + val weeklySummaryLoading: Boolean = false, + val weeklySummaryError: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardViewModel.kt b/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardViewModel.kt new file mode 100644 index 0000000..f0e6e4e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/dashboard/ui/DashboardViewModel.kt @@ -0,0 +1,130 @@ +package com.sampoom.android.feature.dashboard.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sampoom.android.core.network.serverMessageOrNull +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.auth.domain.model.User +import com.sampoom.android.feature.auth.domain.usecase.GetStoredUserUseCase +import com.sampoom.android.feature.dashboard.domain.usecase.GetDashboardUseCase +import com.sampoom.android.feature.dashboard.domain.usecase.WeeklySummaryUseCase +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.usecase.GetOrderUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +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 DashboardViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + private val getOrderListUseCase: GetOrderUseCase, + private val getStoredUserUseCase: GetStoredUserUseCase, + private val getDashboardUseCase: GetDashboardUseCase, + private val getWeeklySummaryUseCase: WeeklySummaryUseCase +): ViewModel() { + + private companion object { + private const val TAG = "DashboardViewModel" + } + + private val _uiState = MutableStateFlow(DashboardUiState()) + val uiState : StateFlow = _uiState + + private val _user = MutableStateFlow(null) + val user: StateFlow = _user + + val orderListPaged: Flow> = getOrderListUseCase() + .cachedIn(viewModelScope) + + private var errorLabel: String = "" + + fun bindLabel(error: String) { + errorLabel = error + } + + init { + loadDashboard() + loadWeeklySummary() + viewModelScope.launch { + _user.value = getStoredUserUseCase() + } + } + + fun onEvent(event: DashboardUiEvent) { + when (event) { + is DashboardUiEvent.LoadDashboard -> { + loadDashboard() + loadWeeklySummary() + } + is DashboardUiEvent.RetryDashboard -> { + loadDashboard() + loadWeeklySummary() + } + } + } + + private fun loadDashboard() { + viewModelScope.launch { + _uiState.update { it.copy(dashboardLoading = true, dashboardError = null) } + + getDashboardUseCase() + .onSuccess { dashboard -> + _uiState.update { + it.copy( + dashboard = dashboard, + dashboardLoading = false, + dashboardError = null + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + dashboardLoading = false, + dashboardError = error + ) + } + } + } + } + + private fun loadWeeklySummary() { + viewModelScope.launch { + _uiState.update { it.copy(weeklySummaryLoading = true, weeklySummaryError = null) } + + getWeeklySummaryUseCase() + .onSuccess { weeklySummary -> + _uiState.update { + it.copy( + weeklySummary = weeklySummary, + weeklySummaryLoading = false, + weeklySummaryError = null + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + weeklySummaryLoading = false, + weeklySummaryError = error + ) + } + } + } + } +} \ 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..e2d37c9 --- /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, standardCost) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/data/paging/OrderPagingSource.kt b/app/src/main/java/com/sampoom/android/feature/order/data/paging/OrderPagingSource.kt new file mode 100644 index 0000000..e7e52fe --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/paging/OrderPagingSource.kt @@ -0,0 +1,47 @@ +package com.sampoom.android.feature.order.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.sampoom.android.core.preferences.AuthPreferences +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.Order +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +class OrderPagingSource @AssistedInject constructor( + private val api: OrderApi, + private val authPreferences: AuthPreferences +) : PagingSource() { + + @AssistedFactory + interface Factory { + fun create(): OrderPagingSource + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val agencyName = authPreferences.getStoredUser()?.branch ?: throw Exception() + val page = params.key ?: 0 + val pageSize = params.loadSize + val response = api.getOrderList(agencyName, page, pageSize) + + val orders = response.data.content.map { it.toModel() } + + LoadResult.Page( + data = orders, + prevKey = if (page == 0) null else page - 1, + nextKey = if (page < response.data.totalPages - 1) page + 1 else null + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} \ 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..f168398 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/remote/api/OrderApi.kt @@ -0,0 +1,47 @@ +package com.sampoom.android.feature.order.data.remote.api + +import com.sampoom.android.core.model.ApiResponse +import com.sampoom.android.core.model.ApiSuccessResponse +import com.sampoom.android.feature.order.data.remote.dto.OrderDto +import com.sampoom.android.feature.order.data.remote.dto.OrderListDto +import com.sampoom.android.feature.order.data.remote.dto.OrderRequestDto +import com.sampoom.android.feature.order.data.remote.dto.ReceiveStockRequestDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface OrderApi { + // 주문 목록 조회 + @GET("order/requested") + suspend fun getOrderList( + @Query("from") agencyName: String, + @Query("page") page: Int = 0, + @Query("size") size: Int = 20 + ): ApiResponse + + // 주문 생성 + @POST("order/") + suspend fun createOrder(@Body orderRequestDto: OrderRequestDto): ApiResponse + + // 주문 완료 처리 + @PATCH("order/complete/{orderId}") + suspend fun completeOrder(@Path("orderId") orderId: Long): ApiSuccessResponse + + // 주문 입고 처리 (대리점) + @PATCH("agency/{agencyId}/stock") + suspend fun receiveOrder( + @Path("agencyId") agencyId: Long, + @Body body: ReceiveStockRequestDto + ): ApiSuccessResponse + + // 주문 상세 조회 + @GET("order/{orderId}") + suspend fun getOrderDetail(@Path("orderId") orderId: Long): ApiResponse + + // 주문 취소 + @PATCH("order/cancel/{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..6fe631d --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderDto.kt @@ -0,0 +1,32 @@ +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, + val standardCost: Long +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderListDto.kt b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderListDto.kt new file mode 100644 index 0000000..8859d14 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderListDto.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.order.data.remote.dto + +data class OrderListDto( + val content: List, + val totalElements: Int, + val totalPages: Int, + val currentPage: Int +) diff --git a/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderRequestDto.kt new file mode 100644 index 0000000..abee6e4 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/OrderRequestDto.kt @@ -0,0 +1,6 @@ +package com.sampoom.android.feature.order.data.remote.dto + +data class OrderRequestDto( + val agencyName: String, // branch + val items: List +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/ReceiveStockItemDto.kt b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/ReceiveStockItemDto.kt new file mode 100644 index 0000000..677fe9e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/ReceiveStockItemDto.kt @@ -0,0 +1,6 @@ +package com.sampoom.android.feature.order.data.remote.dto + +data class ReceiveStockItemDto( + val partId: Long, + val quantity: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/ReceiveStockRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/ReceiveStockRequestDto.kt new file mode 100644 index 0000000..4e658be --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/remote/dto/ReceiveStockRequestDto.kt @@ -0,0 +1,5 @@ +package com.sampoom.android.feature.order.data.remote.dto + +data class ReceiveStockRequestDto( + val items: List +) 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..8190a7c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/data/repository/OrderRepositoryImpl.kt @@ -0,0 +1,104 @@ +package com.sampoom.android.feature.order.data.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.feature.cart.domain.model.CartList +import com.sampoom.android.feature.order.data.mapper.toModel +import com.sampoom.android.feature.order.data.paging.OrderPagingSource +import com.sampoom.android.feature.order.data.remote.api.OrderApi +import com.sampoom.android.feature.order.data.remote.dto.OrderCategoryDto +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.data.remote.dto.OrderRequestDto +import com.sampoom.android.feature.order.data.remote.dto.ReceiveStockItemDto +import com.sampoom.android.feature.order.data.remote.dto.ReceiveStockRequestDto +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.repository.OrderRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class OrderRepositoryImpl @Inject constructor( + private val api: OrderApi, + private val authPreferences: AuthPreferences, + private val pagingSourceFactory: OrderPagingSource.Factory +) : OrderRepository { + override fun getOrderList(): Flow> { + return Pager( + config = PagingConfig(pageSize = 20), + pagingSourceFactory = { pagingSourceFactory.create() } + ).flow + } + + override suspend fun createOrder(cartList: CartList): Result { + return runCatching { + val agencyName = authPreferences.getStoredUser()?.branch ?: throw Exception() + val items = cartList.items.map { cart -> + OrderCategoryDto( + categoryId = cart.categoryId, + categoryName = cart.categoryName, + groups = cart.groups.map { group -> + OrderGroupDto( + groupId = group.groupId, + groupName = group.groupName, + parts = group.parts.map { part -> + OrderPartDto( + partId = part.partId, + code = part.code, + name = part.name, + quantity = part.quantity, + standardCost = part.standardCost + ) + } + ) + } + ) + } + + val request = OrderRequestDto( + agencyName = agencyName, + items = items + ) + + val dto = api.createOrder(request) + if (!dto.success) throw Exception(dto.message) + dto.data.toModel() + } + } + + override suspend fun completeOrder(orderId: Long): Result { + return runCatching { + val dto = api.completeOrder(orderId) + if (!dto.success) throw Exception(dto.message) + } + } + + override suspend fun receiveOrder(items: List>): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val body = ReceiveStockRequestDto( + items = items.map { (partId, quantity) -> + ReceiveStockItemDto(partId = partId, quantity = quantity) + } + ) + val dto = api.receiveOrder(agencyId = agencyId, body = body) + if (!dto.success) throw Exception(dto.message) + } + } + + override suspend fun getOrderDetail(orderId: Long): Result { + return runCatching { + val dto = api.getOrderDetail(orderId) + if (!dto.success) throw Exception(dto.message) + dto.data.toModel() + } + } + + override suspend fun cancelOrder(orderId: Long): Result { + return runCatching { + val dto = api.cancelOrder(orderId) + if (!dto.success) throw Exception(dto.message) + } + } +} \ 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..5258756 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/model/Order.kt @@ -0,0 +1,41 @@ +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, + val standardCost: Long +) + +// 파일 하단에 추가 +val OrderPart.subtotal: Long + get() = standardCost * quantity + +val Order.totalCost: Long + get() = items.sumOf { category -> + category.groups.sumOf { group -> + group.parts.sumOf { it.subtotal } + } + } \ No newline at end of file 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..2eb62ea --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/model/OrderStatus.kt @@ -0,0 +1,21 @@ +package com.sampoom.android.feature.order.domain.model + +import java.util.Locale + +enum class OrderStatus { + PENDING, CONFIRMED, SHIPPING, DELAYED, PRODUCING, ARRIVED, COMPLETED, CANCELED; + + companion object { + fun from(raw: String?): OrderStatus = when (raw?.uppercase(Locale.ROOT)) { + "PENDING" -> PENDING + "CONFIRMED" -> CONFIRMED + "SHIPPING" -> SHIPPING + "DELAYED" -> DELAYED + "PRODUCING" -> PRODUCING + "ARRIVED" -> ARRIVED + "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..3ea34cd --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/repository/OrderRepository.kt @@ -0,0 +1,16 @@ +package com.sampoom.android.feature.order.domain.repository + +import androidx.paging.PagingData +import com.sampoom.android.feature.cart.domain.model.CartList +import com.sampoom.android.feature.order.domain.model.Order +import kotlinx.coroutines.flow.Flow + +interface OrderRepository { +// suspend fun getOrderList(): Result + fun getOrderList(): Flow> + suspend fun createOrder(cartList: CartList): Result + suspend fun completeOrder(orderId: Long): Result + suspend fun receiveOrder(items: List>): Result + suspend fun getOrderDetail(orderId: Long): Result + 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..a3c393d --- /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): Result = repository.cancelOrder(orderId) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CompleteOrderUseCase.kt b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CompleteOrderUseCase.kt new file mode 100644 index 0000000..d433c14 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CompleteOrderUseCase.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 CompleteOrderUseCase @Inject constructor( + private val repository: OrderRepository +) { + suspend operator fun invoke(orderId: Long): Result = repository.completeOrder(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..868c772 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/CreateOrderUseCase.kt @@ -0,0 +1,12 @@ +package com.sampoom.android.feature.order.domain.usecase + +import com.sampoom.android.feature.cart.domain.model.CartList +import com.sampoom.android.feature.order.domain.model.Order +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(cartList: CartList): Result = repository.createOrder(cartList) +} \ 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..3e3b73c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/GetOrderDetailUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.order.domain.usecase + +import com.sampoom.android.feature.order.domain.model.Order +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): Result = 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..6df4370 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/domain/usecase/GetOrderUseCase.kt @@ -0,0 +1,13 @@ +package com.sampoom.android.feature.order.domain.usecase + +import androidx.paging.PagingData +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.repository.OrderRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetOrderUseCase @Inject constructor( + private val repository: OrderRepository +){ + operator fun invoke(): Flow> = 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..c8829ce --- /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(items: List>): Result = repository.receiveOrder(items) +} \ 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..6109771 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailContent.kt @@ -0,0 +1,235 @@ +package com.sampoom.android.feature.order.ui + +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Divider +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.text.style.TextAlign +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.disableColor +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.core.ui.theme.textSecondaryColor +import com.sampoom.android.core.util.formatWon +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.model.OrderPart +import com.sampoom.android.feature.order.domain.model.subtotal +import com.sampoom.android.feature.order.domain.model.totalCost +import kotlin.collections.forEach + +@Composable +fun OrderDetailContent( + order: Order, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + 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 + ) + } + } + } + item { Spacer(Modifier.height(50.dp).fillMaxWidth()) } + } +} + + +@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) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.order_detail_total_amount), + style = MaterialTheme.typography.bodyMedium, + color = textSecondaryColor() + ) + Text( + text = formatWon(order.totalCost), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +@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()) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + 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() + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatWon(part.standardCost), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "x ${part.quantity}", + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Spacer(Modifier.padding(4.dp)) + Divider(Modifier.background(textSecondaryColor())) + Spacer(Modifier.padding(4.dp)) + + Text( + text = formatWon(part.subtotal), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth() + ) + } + } +} \ 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..fb72131 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailScreen.kt @@ -0,0 +1,218 @@ +package com.sampoom.android.feature.order.ui + +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.navigationBarsPadding +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.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.ErrorContent +import com.sampoom.android.feature.order.domain.model.OrderStatus + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrderDetailScreen( + onNavigateBack: () -> Unit = {}, + viewModel: OrderDetailViewModel = hiltViewModel() +) { + val errorLabel = stringResource(R.string.common_error) + val cancelLabel = stringResource(R.string.order_detail_toast_order_cancel) + val receiveLabel = stringResource(R.string.order_detail_toast_order_receive) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() + var showCancelOrderDialog by remember { mutableStateOf(false) } + var showReceiveOrderDialog by remember { mutableStateOf(false) } + + LaunchedEffect(errorLabel, cancelLabel, receiveLabel) { + viewModel.bindLabel(errorLabel, cancelLabel, receiveLabel) + } + + // 성공 시 Toast 표시 후 다이얼로그 닫기 + LaunchedEffect(uiState.isProcessingCancelSuccess) { + if (uiState.isProcessingCancelSuccess) { + viewModel.clearSuccess() + viewModel.onEvent(OrderDetailUiEvent.LoadOrder) + } + } + + // 성공 시 Toast 표시 후 다이얼로그 닫기 + LaunchedEffect(uiState.isProcessingReceiveSuccess) { + if (uiState.isProcessingReceiveSuccess) { + viewModel.clearSuccess() + viewModel.onEvent(OrderDetailUiEvent.LoadOrder) + } + } + + PullToRefreshBox( + isRefreshing = false, + 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?.status + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .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.ARRIVED, + 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() + ) + } + + else -> { + uiState.orderDetail?.let { order -> + OrderDetailContent( + order = order, + 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..41a3f6a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailUiEvent.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.order.ui + +sealed interface OrderDetailUiEvent { + object LoadOrder : OrderDetailUiEvent + object RetryOrder : OrderDetailUiEvent + object ReceiveOrder : OrderDetailUiEvent + object CancelOrder : 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..bfa7f3b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailUiState.kt @@ -0,0 +1,12 @@ +package com.sampoom.android.feature.order.ui + +import com.sampoom.android.feature.order.domain.model.Order + +data class OrderDetailUiState( + val orderDetail: Order? = null, + val orderDetailLoading: Boolean = false, + val orderDetailError: String? = null, + val isProcessing: Boolean = false, + val isProcessingCancelSuccess: Boolean = false, + val isProcessingReceiveSuccess: Boolean = false, +) \ 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..494ed25 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderDetailViewModel.kt @@ -0,0 +1,177 @@ +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.core.util.GlobalMessageHandler +import com.sampoom.android.feature.order.domain.usecase.CancelOrderUseCase +import com.sampoom.android.feature.order.domain.usecase.CompleteOrderUseCase +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.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 messageHandler: GlobalMessageHandler, + private val getOrderDetailUseCase: GetOrderDetailUseCase, + private val cancelOrderUseCase: CancelOrderUseCase, + private val completeOrderUseCase: CompleteOrderUseCase, + 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 = "" + private var cancelLabel: String = "" + private var receiveLabel: String = "" + + fun bindLabel(error: String, cancel: String, receive: String) { + errorLabel = error + cancelLabel = cancel + receiveLabel = receive + } + + 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()) + } + } + + private fun loadOrderDetail(orderId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(orderDetailLoading = true, orderDetailError = null) } + + getOrderDetailUseCase(orderId) + .onSuccess { order -> + _uiState.update { + it.copy( + orderDetail = order, + orderDetailLoading = false, + orderDetailError = null + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + orderDetailLoading = false, + orderDetailError = error + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun cancelOrder(orderId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isProcessing = true) } + + cancelOrderUseCase(orderId) + .onSuccess { + messageHandler.showMessage(message = cancelLabel, isError = false) + _uiState.update { + it.copy( + isProcessing = false, + isProcessingCancelSuccess = true + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy(isProcessing = false) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun receiveOrder(orderId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isProcessing = true) } + try { + val order = _uiState.value.orderDetail ?: throw Exception() + val items: List> = order.items.flatMap { category -> + category.groups.flatMap { group -> + group.parts.map { part -> part.partId to part.quantity } + } + } + completeOrderUseCase(orderId) + .onFailure { t -> + val error = t.serverMessageOrNull() ?: (t.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + _uiState.update { it.copy(isProcessing = false) } + return@launch + } + receiveOrderUseCase(items) + .onFailure { t -> + val error = t.serverMessageOrNull() ?: (t.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + _uiState.update { it.copy(isProcessing = false) } + return@launch + } + + messageHandler.showMessage(message = receiveLabel, isError = false) + _uiState.update { + it.copy( + isProcessing = false, + isProcessingReceiveSuccess = true + ) + } + } catch (t: Throwable) { + val backendMessage = t.serverMessageOrNull() + val error = backendMessage ?: (t.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + _uiState.update { it.copy(isProcessing = false) } + } + 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/OrderItem.kt b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderItem.kt new file mode 100644 index 0000000..8614242 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderItem.kt @@ -0,0 +1,92 @@ +package com.sampoom.android.feature.order.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.text.style.TextAlign +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.textSecondaryColor +import com.sampoom.android.core.util.buildOrderTitle +import com.sampoom.android.core.util.formatDate +import com.sampoom.android.core.util.formatWon +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.model.totalCost + +@Composable +fun OrderItem( + order: Order, + onClick: () -> Unit +) { + Card( + onClick = { onClick() }, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundCardColor()) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + 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) + } + } + + Spacer(Modifier.height(4.dp)) + + Text( + text = formatWon(order.totalCost), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth() + ) + } + } +} \ 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..1e93c4e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListScreen.kt @@ -0,0 +1,190 @@ +package com.sampoom.android.feature.order.ui + +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.ExperimentalMaterialApi +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 androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +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.theme.FailRed +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.feature.order.domain.model.Order + +@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 orderListPaged = viewModel.orderListPaged.collectAsLazyPagingItems() + val pullRefreshState = rememberPullToRefreshState() + val listState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + + LaunchedEffect(errorLabel) { + viewModel.bindLabel(errorLabel) + } + + LaunchedEffect(Unit) { + orderListPaged.refresh() + } + + PullToRefreshBox( + isRefreshing = false, + onRefresh = { orderListPaged.refresh() }, + 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 (orderListPaged.loadState.refresh) { + is LoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is LoadState.Error -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorContent( + onRetry = { orderListPaged.retry() }, + modifier = Modifier.height(200.dp) + ) + } + } + + else -> { + // 빈 상태 처리 + if (orderListPaged.loadState.refresh !is LoadState.Loading && orderListPaged.itemCount == 0) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + EmptyContent( + message = stringResource(R.string.order_empty_list), + modifier = Modifier.height(200.dp) + ) + } + } + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + count = orderListPaged.itemCount, + key = orderListPaged.itemKey { it.orderId } + ) { index -> + val order = orderListPaged[index] + if (order != null) { + OrderItem( + order = order, + onClick = { onNavigateOrderDetail(order) } + ) + } + } + + // 로딩 상태 처리 + item { + when (orderListPaged.loadState.append) { + is LoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is LoadState.Error -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.common_error), + color = FailRed + ) + } + } + else -> {} + } + } + + item { Spacer(Modifier.height(100.dp)) } + } + } + } + } + } +} \ 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..c7bc0b4 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderListViewModel.kt @@ -0,0 +1,44 @@ +package com.sampoom.android.feature.order.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.order.domain.model.Order +import com.sampoom.android.feature.order.domain.usecase.GetOrderUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@HiltViewModel +class OrderListViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + 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 = "" + + fun bindLabel(error: String) { + errorLabel = error + } + + val orderListPaged: Flow> = getOrderListUseCase() + .cachedIn(viewModelScope) + + fun onEvent(event: OrderListUiEvent) { + when (event) { + is OrderListUiEvent.LoadOrderList -> {}//loadOrderList() + is OrderListUiEvent.RetryOrderList -> {}//loadOrderList() + } + } +} \ 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..0f3a050 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/order/ui/OrderResultBottomSheet.kt @@ -0,0 +1,169 @@ +package com.sampoom.android.feature.order.ui + +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: Order, + 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.orderId) { + viewModel.setOrderIdFromApi(order.orderId) + viewModel.onEvent(OrderDetailUiEvent.LoadOrder) + } + + val displayedOrder = uiState.orderDetail ?: order + + // 성공 시 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) + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxHeight(0.9f) + ) { + // 주문 완료 헤더 + OrderCompleteHeader() + + Spacer(modifier = Modifier.height(16.dp)) + + // OrderDetailContent 재사용 + OrderDetailContent( + order = displayedOrder, + modifier = Modifier.weight(1f) + ) + + // 하단 고정 버튼들 + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + val orderStatus = displayedOrder.status + CommonButton( + modifier = Modifier.weight(1f), + variant = ButtonVariant.Error, + enabled = !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/mapper/OutboundMappers.kt b/app/src/main/java/com/sampoom/android/feature/outbound/data/mapper/OutboundMappers.kt new file mode 100644 index 0000000..ab25cfa --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/data/mapper/OutboundMappers.kt @@ -0,0 +1,12 @@ +package com.sampoom.android.feature.outbound.data.mapper + +import com.sampoom.android.feature.outbound.data.remote.dto.OutboundDto +import com.sampoom.android.feature.outbound.data.remote.dto.OutboundGroupDto +import com.sampoom.android.feature.outbound.data.remote.dto.OutboundPartDto +import com.sampoom.android.feature.outbound.domain.model.Outbound +import com.sampoom.android.feature.outbound.domain.model.OutboundGroup +import com.sampoom.android.feature.outbound.domain.model.OutboundPart + +fun OutboundDto.toModel(): Outbound = Outbound(categoryId, categoryName, groups.map { it.toModel() }) +fun OutboundGroupDto.toModel(): OutboundGroup = OutboundGroup(groupId, groupName, parts.map { it.toModel() }) +fun OutboundPartDto.toModel(): OutboundPart = OutboundPart(outboundId, partId, code, name, quantity, standardCost) \ 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 new file mode 100644 index 0000000..b4209ec --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/api/OutboundApi.kt @@ -0,0 +1,49 @@ +package com.sampoom.android.feature.outbound.data.remote.api + +import com.sampoom.android.core.model.ApiResponse +import com.sampoom.android.core.model.ApiSuccessResponse +import com.sampoom.android.feature.outbound.data.remote.dto.AddOutboundRequestDto +import com.sampoom.android.feature.outbound.data.remote.dto.OutboundDto +import com.sampoom.android.feature.outbound.data.remote.dto.UpdateOutboundRequestDto +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path + +interface OutboundApi { + // 출고 목록 조회 + @GET("agency/{agencyId}/outbound") + suspend fun getOutboundList(@Path("agencyId") agencyId: Long): ApiResponse> + + // 출고 목록에 부품 추가 + @POST("agency/{agencyId}/outbound") + suspend fun addOutbound( + @Path("agencyId") agencyId: Long, + @Body body: AddOutboundRequestDto + ): ApiSuccessResponse + + // 출고 처리 + @POST("agency/{agencyId}/outbound/process") + suspend fun processOutbound(@Path("agencyId") agencyId: Long): ApiSuccessResponse + + // 출고 항목 삭제 + @DELETE("agency/{agencyId}/outbound/{outboundId}") + suspend fun deleteOutbound( + @Path("agencyId") agencyId: Long, + @Path("outboundId") outboundId: Long + ): ApiSuccessResponse + + // 출고 수량 변경 + @PATCH("agency/{agencyId}/outbound/{outboundId}") + suspend fun updateOutbound( + @Path("agencyId") agencyId: Long, + @Path("outboundId") outboundId: Long, + @Body body: UpdateOutboundRequestDto + ): ApiSuccessResponse + + // 출고 목록 전체 비우기 + @DELETE("agency/{agencyId}/outbound/clear") + suspend fun deleteAllOutbound(@Path("agencyId") agencyId: Long): ApiSuccessResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/dto/AddOutboundRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/dto/AddOutboundRequestDto.kt new file mode 100644 index 0000000..f3f0ca7 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/dto/AddOutboundRequestDto.kt @@ -0,0 +1,6 @@ +package com.sampoom.android.feature.outbound.data.remote.dto + +data class AddOutboundRequestDto( + val partId: Long, + val quantity: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/dto/OutboundDto.kt b/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/dto/OutboundDto.kt new file mode 100644 index 0000000..5c7174b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/dto/OutboundDto.kt @@ -0,0 +1,22 @@ +package com.sampoom.android.feature.outbound.data.remote.dto + +data class OutboundDto( + val categoryId: Long, + val categoryName: String, + val groups: List +) + +data class OutboundGroupDto( + val groupId: Long, + val groupName: String, + val parts: List +) + +data class OutboundPartDto( + val outboundId: Long, + val partId: Long, + val code: String, + val name: String, + val quantity: Long, + val standardCost: Long +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/dto/UpdateOutboundRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/dto/UpdateOutboundRequestDto.kt new file mode 100644 index 0000000..b0e542b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/data/remote/dto/UpdateOutboundRequestDto.kt @@ -0,0 +1,5 @@ +package com.sampoom.android.feature.outbound.data.remote.dto + +data class UpdateOutboundRequestDto( + val quantity: Long +) \ No newline at end of file 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 new file mode 100644 index 0000000..bb3fe7b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/data/repository/OutboundRepositoryImpl.kt @@ -0,0 +1,75 @@ +package com.sampoom.android.feature.outbound.data.repository + +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.feature.outbound.data.mapper.toModel +import com.sampoom.android.feature.outbound.data.remote.api.OutboundApi +import com.sampoom.android.feature.outbound.data.remote.dto.AddOutboundRequestDto +import com.sampoom.android.feature.outbound.data.remote.dto.UpdateOutboundRequestDto +import com.sampoom.android.feature.outbound.domain.model.OutboundList +import com.sampoom.android.feature.outbound.domain.repository.OutboundRepository +import jakarta.inject.Inject + +class OutboundRepositoryImpl @Inject constructor( + private val api: OutboundApi, + private val authPreferences: AuthPreferences +) : OutboundRepository { + override suspend fun getOutboundList(): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.getOutboundList(agencyId) + val outboundItems = dto.data.map { it.toModel() } + OutboundList(items = outboundItems) + } + } + + override suspend fun processOutbound(): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.processOutbound(agencyId) + if (!dto.success) throw Exception(dto.message) + } + } + + override suspend fun addOutbound( + partId: Long, + quantity: Long + ): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = + api.addOutbound(agencyId = agencyId, body = AddOutboundRequestDto(partId, quantity)) + if (!dto.success) throw Exception(dto.message) + } + } + + override suspend fun deleteOutbound(outboundId: Long): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.deleteOutbound(agencyId = agencyId, outboundId = outboundId) + if (!dto.success) throw Exception(dto.message) + } + } + + override suspend fun deleteAllOutbound(): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.deleteAllOutbound(agencyId) + if (!dto.success) throw Exception(dto.message) + } + } + + override suspend fun updateOutboundQuantity( + outboundId: Long, + quantity: Long + ): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val dto = api.updateOutbound( + agencyId = agencyId, + outboundId = outboundId, + body = UpdateOutboundRequestDto(quantity) + ) + if (!dto.success) throw Exception(dto.message) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/di/OutboundModules.kt b/app/src/main/java/com/sampoom/android/feature/outbound/di/OutboundModules.kt new file mode 100644 index 0000000..7aa5276 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/di/OutboundModules.kt @@ -0,0 +1,27 @@ +package com.sampoom.android.feature.outbound.di + +import com.sampoom.android.feature.outbound.data.remote.api.OutboundApi +import com.sampoom.android.feature.outbound.data.repository.OutboundRepositoryImpl +import com.sampoom.android.feature.outbound.domain.repository.OutboundRepository +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 +import kotlin.jvm.java + +@Module +@InstallIn(SingletonComponent::class) +abstract class OutboundBinModule { + @Binds @Singleton + abstract fun bindOutboundRepository(impl: OutboundRepositoryImpl): OutboundRepository +} + +@Module +@InstallIn(SingletonComponent::class) +object OutboundModule { + @Provides @Singleton + fun provideOutboundApi(retrofit: Retrofit): OutboundApi = retrofit.create(OutboundApi::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/domain/model/Outbound.kt b/app/src/main/java/com/sampoom/android/feature/outbound/domain/model/Outbound.kt new file mode 100644 index 0000000..2610cfe --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/domain/model/Outbound.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.feature.outbound.domain.model + +data class Outbound( + val categoryId: Long, + val categoryName: String, + val groups: List +) + +data class OutboundGroup( + val groupId: Long, + val groupName: String, + val parts: List +) + +data class OutboundPart( + val outboundId: Long, + val partId: Long, + val code: String, + val name: String, + val quantity: Long, + val standardCost: Long +) + +// 품목 합계(단가 x 수량) +val OutboundPart.subtotal: Long + get() = standardCost * quantity \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/domain/model/OutboundList.kt b/app/src/main/java/com/sampoom/android/feature/outbound/domain/model/OutboundList.kt new file mode 100644 index 0000000..16ced52 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/domain/model/OutboundList.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.outbound.domain.model + +data class OutboundList( + val items: List, + val totalCount: Int = items.size, + val isEmpty: Boolean = items.isEmpty() +) { + companion object Companion { + fun empty() = OutboundList(emptyList()) + } +} diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/domain/repository/OutboundRepository.kt b/app/src/main/java/com/sampoom/android/feature/outbound/domain/repository/OutboundRepository.kt new file mode 100644 index 0000000..cbe897b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/domain/repository/OutboundRepository.kt @@ -0,0 +1,12 @@ +package com.sampoom.android.feature.outbound.domain.repository + +import com.sampoom.android.feature.outbound.domain.model.OutboundList + +interface OutboundRepository { + suspend fun getOutboundList(): Result + suspend fun processOutbound(): Result + suspend fun addOutbound(partId: Long, quantity: Long): Result + suspend fun deleteOutbound(outboundId: Long): Result + suspend fun deleteAllOutbound(): Result + suspend fun updateOutboundQuantity(outboundId: Long, quantity: Long): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/AddOutboundUseCase.kt b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/AddOutboundUseCase.kt new file mode 100644 index 0000000..d119f92 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/AddOutboundUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.outbound.domain.usecase + +import com.sampoom.android.feature.outbound.domain.repository.OutboundRepository +import javax.inject.Inject + +class AddOutboundUseCase @Inject constructor( + private val repository: OutboundRepository +) { + suspend operator fun invoke(partId: Long, quantity: Long): Result = repository.addOutbound(partId, quantity) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/DeleteAllOutboundUseCase.kt b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/DeleteAllOutboundUseCase.kt new file mode 100644 index 0000000..0db5a79 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/DeleteAllOutboundUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.outbound.domain.usecase + +import com.sampoom.android.feature.outbound.domain.repository.OutboundRepository +import javax.inject.Inject + +class DeleteAllOutboundUseCase @Inject constructor( + private val repository: OutboundRepository +){ + suspend operator fun invoke(): Result = repository.deleteAllOutbound() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/DeleteOutboundUseCase.kt b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/DeleteOutboundUseCase.kt new file mode 100644 index 0000000..93eb3df --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/DeleteOutboundUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.outbound.domain.usecase + +import com.sampoom.android.feature.outbound.domain.repository.OutboundRepository +import javax.inject.Inject + +class DeleteOutboundUseCase @Inject constructor( + private val repository: OutboundRepository +){ + suspend operator fun invoke(outboundId: Long): Result = repository.deleteOutbound(outboundId) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/GetOutboundUseCase.kt b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/GetOutboundUseCase.kt new file mode 100644 index 0000000..7189715 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/GetOutboundUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.outbound.domain.usecase + +import com.sampoom.android.feature.outbound.domain.model.OutboundList +import com.sampoom.android.feature.outbound.domain.repository.OutboundRepository +import javax.inject.Inject + +class GetOutboundUseCase @Inject constructor( + private val repository: OutboundRepository +) { + suspend operator fun invoke(): Result = repository.getOutboundList() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/ProcessOutboundUseCase.kt b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/ProcessOutboundUseCase.kt new file mode 100644 index 0000000..ab54b7d --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/ProcessOutboundUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.outbound.domain.usecase + +import com.sampoom.android.feature.outbound.domain.repository.OutboundRepository +import javax.inject.Inject + +class ProcessOutboundUseCase @Inject constructor( + private val repository: OutboundRepository +){ + suspend operator fun invoke(): Result = repository.processOutbound() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/UpdateOutboundQuantityUseCase.kt b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/UpdateOutboundQuantityUseCase.kt new file mode 100644 index 0000000..dcf4cf7 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/domain/usecase/UpdateOutboundQuantityUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.outbound.domain.usecase + +import com.sampoom.android.feature.outbound.domain.repository.OutboundRepository +import javax.inject.Inject + +class UpdateOutboundQuantityUseCase @Inject constructor( + private val repository: OutboundRepository +) { + suspend operator fun invoke(outboundId: Long, quantity: Long): Result = repository.updateOutboundQuantity(outboundId, quantity) +} \ 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 new file mode 100644 index 0000000..dabc090 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListScreen.kt @@ -0,0 +1,412 @@ +package com.sampoom.android.feature.outbound.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 +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.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 +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.ButtonSize +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.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.core.util.formatWon +import com.sampoom.android.feature.cart.domain.model.subtotal +import com.sampoom.android.feature.outbound.domain.model.OutboundPart +import com.sampoom.android.feature.outbound.domain.model.subtotal + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutboundListScreen( + paddingValues: PaddingValues, + viewModel: OutboundListViewModel = hiltViewModel() +) { + val errorLabel = stringResource(R.string.common_error) + val successLabel = stringResource(R.string.outbound_toast_order_text) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() + var showEmptyOutboundDialog by remember { mutableStateOf(false) } + var showConfirmDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.clearSuccess() + } + + LaunchedEffect(errorLabel) { + viewModel.bindLabel(errorLabel, successLabel) + viewModel.onEvent(OutboundListUiEvent.LoadOutboundList) + } + + LaunchedEffect(uiState.isOrderSuccess) { + if (uiState.isOrderSuccess) { + viewModel.clearSuccess() + } + } + PullToRefreshBox( + isRefreshing = false, + 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 + ) + } + ) { + 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.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 + ) + } + } + } + } + + when { + uiState.outboundLoading -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + uiState.outboundError != null -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorContent( + onRetry = { viewModel.onEvent(OutboundListUiEvent.RetryOutboundList) }, + modifier = Modifier.height(200.dp) + ) + } + } + + uiState.outboundList.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + 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)) } + } + + CommonButton( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomEnd) + .padding(16.dp) + .padding(end = 72.dp), + variant = ButtonVariant.Error, + size = ButtonSize.Large, + onClick = { showConfirmDialog = true } + ) { Text("${formatWon(uiState.totalCost)} ${stringResource(R.string.outbound_order_parts)}") } + } + } + } + } + } + + if (showEmptyOutboundDialog) { + AlertDialog( + onDismissRequest = { showEmptyOutboundDialog = false }, + text = { Text(stringResource(R.string.outbound_dialog_empty_text)) }, + confirmButton = { + TextButton( + onClick = { + showEmptyOutboundDialog = false + viewModel.onEvent(OutboundListUiEvent.DeleteAllOutbound) + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showEmptyOutboundDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } + + if (showConfirmDialog) { + AlertDialog( + onDismissRequest = { showConfirmDialog = false }, + text = { Text(stringResource(R.string.outbound_dialog_order_text)) }, + confirmButton = { + TextButton( + onClick = { + showConfirmDialog = false + viewModel.onEvent(OutboundListUiEvent.ProcessOutbound) + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showConfirmDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } +} + +@Composable +private fun OutboundSection( + categoryName: String, + groupName: String, + parts: List, + isUpdating: Boolean, + isDeleting: Boolean, + onEvent: (OutboundListUiEvent) -> Unit +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "$categoryName > $groupName", + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + + parts.forEach { part -> + OutboundPartItem( + part = part, + isUpdating = isUpdating, + isDeleting = isDeleting, + onEvent = onEvent + ) + } + } +} + +@Composable +private fun OutboundPartItem( + part: OutboundPart, + isUpdating: Boolean, + isDeleting: Boolean, + onEvent: (OutboundListUiEvent) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundCardColor()) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1F) + ) { + Text( + text = part.name, + style = MaterialTheme.typography.titleMedium, + color = textColor(), + modifier = Modifier.fillMaxWidth() + ) + Text( + text = part.code, + style = MaterialTheme.typography.bodySmall, + color = textSecondaryColor(), + modifier = Modifier.fillMaxWidth() + ) + } + + IconButton( + onClick = { + onEvent(OutboundListUiEvent.DeleteOutbound(part.outboundId)) + }, + enabled = !isDeleting + ) { + Icon( + painter = painterResource(R.drawable.delete), + contentDescription = stringResource(R.string.common_delete), + tint = FailRed + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(R.string.part_title_quantity), Modifier.weight(1F)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CommonButton( + variant = ButtonVariant.Neutral, + size = ButtonSize.Large, + onClick = { + if (part.quantity > 1) onEvent( + OutboundListUiEvent.UpdateQuantity( + part.outboundId, + part.quantity - 1 + ) + ) + }, + enabled = !isUpdating && part.quantity > 1 + ) { + Text( + text = stringResource(R.string.part_minus), + style = MaterialTheme.typography.titleLarge + ) + } + + Text( + text = part.quantity.toString(), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 24.dp) + ) + CommonButton( + variant = ButtonVariant.Neutral, + size = ButtonSize.Large, + onClick = { + onEvent( + OutboundListUiEvent.UpdateQuantity( + part.outboundId, + part.quantity + 1 + ) + ) + }, + enabled = !isUpdating + ) { + Text( + text = stringResource(R.string.part_plus), + style = MaterialTheme.typography.titleLarge + ) + } + } + } + val subtotal = part.subtotal + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatWon(subtotal), + style = MaterialTheme.typography.titleLarge + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListUiEvent.kt new file mode 100644 index 0000000..c1f2054 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListUiEvent.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.outbound.ui + +sealed interface OutboundListUiEvent { + object LoadOutboundList : OutboundListUiEvent + object RetryOutboundList : OutboundListUiEvent + object ProcessOutbound : OutboundListUiEvent + data class UpdateQuantity(val outboundId: Long, val quantity: Long) : OutboundListUiEvent + data class DeleteOutbound(val outboundId: Long) : OutboundListUiEvent + object DeleteAllOutbound : OutboundListUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListUiState.kt b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListUiState.kt new file mode 100644 index 0000000..27758c9 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListUiState.kt @@ -0,0 +1,22 @@ +package com.sampoom.android.feature.outbound.ui + +import com.sampoom.android.feature.outbound.domain.model.Outbound + +data class OutboundListUiState( + val outboundList: List = emptyList(), + val outboundLoading: Boolean = false, + val outboundError: String? = null, + val selectedOutbound: Outbound? = null, + val isUpdating: Boolean = false, + val isDeleting: Boolean = false, + val isOrderSuccess: Boolean = false +) { + val totalCost: Long + get() = outboundList.sumOf { category -> + category.groups.sumOf { group -> + group.parts.sumOf { part -> + part.standardCost * part.quantity + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..6eb5820 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/outbound/ui/OutboundListViewModel.kt @@ -0,0 +1,232 @@ +package com.sampoom.android.feature.outbound.ui + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.core.network.serverMessageOrNull +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.outbound.domain.usecase.DeleteAllOutboundUseCase +import com.sampoom.android.feature.outbound.domain.usecase.DeleteOutboundUseCase +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 +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OutboundListViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + private val getOutboundUseCase: GetOutboundUseCase, + private val processOutboundUseCase: ProcessOutboundUseCase, + private val updateOutboundQuantityUseCase: UpdateOutboundQuantityUseCase, + private val deleteOutboundUseCase: DeleteOutboundUseCase, + private val deleteAllOutboundUseCase: DeleteAllOutboundUseCase +) : ViewModel() { + + private companion object { + private const val TAG = "OutboundListViewModel" + } + + private val _uiState = MutableStateFlow(OutboundListUiState()) + val uiState: StateFlow = _uiState + + private var errorLabel: String = "" + private var successLabel: String = "" + + fun bindLabel(error: String, success: String) { + errorLabel = error + successLabel = success + } + + init { + loadOutboundList() + } + + fun onEvent(event: OutboundListUiEvent) { + when (event) { + is OutboundListUiEvent.LoadOutboundList -> loadOutboundList() + is OutboundListUiEvent.RetryOutboundList -> loadOutboundList() + is OutboundListUiEvent.ProcessOutbound -> processOutBound() + is OutboundListUiEvent.UpdateQuantity -> updateQuantity(event.outboundId, event.quantity) + is OutboundListUiEvent.DeleteOutbound -> deleteOutbound(event.outboundId) + is OutboundListUiEvent.DeleteAllOutbound -> deleteAllOutbound() + } + } + + private fun loadOutboundList() { + viewModelScope.launch { + _uiState.update { it.copy(outboundLoading = true, outboundError = null) } + + getOutboundUseCase() + .onSuccess { outboundList -> + _uiState.update { + it.copy( + outboundList = outboundList.items, + outboundLoading = false, + outboundError = null + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + outboundLoading = false, + outboundError = error + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun processOutBound() { + viewModelScope.launch { + _uiState.update { it.copy(outboundLoading = true) } + + processOutboundUseCase() + .onSuccess { + messageHandler.showMessage(message = successLabel, isError = false) + + _uiState.update { it.copy(outboundLoading = false, isOrderSuccess = true) } + loadOutboundList() + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy(outboundLoading = false) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun updateQuantity(outboundId: Long, newQuantity: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isUpdating = true) } + + updateOutboundQuantityUseCase(outboundId, newQuantity) + .onSuccess { + _uiState.update { it.copy(isUpdating = false) } + updateLocalQuantity(outboundId, newQuantity) + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy(isUpdating = false) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun updateLocalQuantity(outboundId: Long, newQuantity: Long) { + _uiState.update { currentState -> + val updatedList = currentState.outboundList.map { category -> + category.copy( + groups = category.groups.map { group -> + group.copy( + parts = group.parts.map { part -> + if (part.outboundId == outboundId) { + part.copy(quantity = newQuantity) + } else { + part + } + } + ) + } + ) + } + currentState.copy(outboundList = updatedList) + } + } + + private fun deleteOutbound(outboundId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isDeleting = true) } + + deleteOutboundUseCase(outboundId) + .onSuccess { + _uiState.update { it.copy(isDeleting = false) } + removeFromLocalList(outboundId) + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy(isDeleting = false) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun deleteAllOutbound() { + viewModelScope.launch { + _uiState.update { it.copy(isDeleting = true) } + + deleteAllOutboundUseCase() + .onSuccess { + _uiState.update { it.copy(isDeleting = false) } + removeAllFromLocalList() + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy(isDeleting = false) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun removeFromLocalList(outboundId: Long) { + _uiState.update { currentState -> + val updatedList = currentState.outboundList.map { category -> + category.copy( + groups = category.groups.map { group -> + group.copy( + parts = group.parts.filter { part -> + part.outboundId != outboundId + } + ) + }.filter { group -> + group.parts.isNotEmpty() + } + ) + }.filter { category -> + category.groups.isNotEmpty() + } + currentState.copy(outboundList = updatedList) + } + } + + private fun removeAllFromLocalList() { + _uiState.update { currentState -> + currentState.copy(outboundList = 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/part/data/mapper/PartMappers.kt b/app/src/main/java/com/sampoom/android/feature/part/data/mapper/PartMappers.kt new file mode 100644 index 0000000..777918a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/data/mapper/PartMappers.kt @@ -0,0 +1,33 @@ +package com.sampoom.android.feature.part.data.mapper + +import com.sampoom.android.feature.part.data.remote.dto.CategoryDto +import com.sampoom.android.feature.part.data.remote.dto.GroupDto +import com.sampoom.android.feature.part.data.remote.dto.PartDto +import com.sampoom.android.feature.part.data.remote.dto.SearchCategoryDto +import com.sampoom.android.feature.part.data.remote.dto.SearchDataDto +import com.sampoom.android.feature.part.domain.model.Category +import com.sampoom.android.feature.part.domain.model.Group +import com.sampoom.android.feature.part.domain.model.Part +import com.sampoom.android.feature.part.domain.model.SearchResult + +fun CategoryDto.toModel(): Category = Category(id, code, name) +fun GroupDto.toModel(): Group = Group(id, code, name, categoryId) +fun PartDto.toModel(): Part = Part(partId, code, name, quantity, standardCost) + +fun SearchCategoryDto.toModel(): List { + return groups.flatMap { group -> + group.parts.map { part -> + SearchResult( + part = part.toModel(), + categoryName = categoryName, + groupName = group.groupName + ) + } + } +} + +fun SearchDataDto.toModel(): List { + return content.flatMap { category -> + category.toModel() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/data/paging/PartPagingSource.kt b/app/src/main/java/com/sampoom/android/feature/part/data/paging/PartPagingSource.kt new file mode 100644 index 0000000..3d1c448 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/data/paging/PartPagingSource.kt @@ -0,0 +1,48 @@ +package com.sampoom.android.feature.part.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.feature.part.data.mapper.toModel +import com.sampoom.android.feature.part.data.remote.api.PartApi +import com.sampoom.android.feature.part.domain.model.SearchResult +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +class PartPagingSource @AssistedInject constructor( + private val api: PartApi, + private val authPreferences: AuthPreferences, + @Assisted private val keyword: String +) : PagingSource() { + + @AssistedFactory + interface Factory { + fun create(keyword: String): PartPagingSource + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val response = api.searchParts(agencyId, keyword, page, 20) + + val flatParts = response.data.toModel() + + LoadResult.Page( + data = flatParts, + prevKey = if (page == 0) null else page - 1, + nextKey = if (page < response.data.totalPages - 1) page + 1 else null + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..ac7c808 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/data/remote/api/PartApi.kt @@ -0,0 +1,32 @@ +package com.sampoom.android.feature.part.data.remote.api + +import com.sampoom.android.core.model.ApiResponse +import com.sampoom.android.feature.part.data.remote.dto.CategoryDto +import com.sampoom.android.feature.part.data.remote.dto.GroupDto +import com.sampoom.android.feature.part.data.remote.dto.PartDto +import com.sampoom.android.feature.part.data.remote.dto.SearchDataDto +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface PartApi { + @GET("agency/category") + suspend fun getCategoryList(): ApiResponse> + + @GET("agency/category/{categoryId}") + suspend fun getGroupList(@Path("categoryId") categoryId: Long): ApiResponse> + + @GET("agency/{agencyId}/group/{groupId}") + suspend fun getPartList( + @Path("agencyId") agencyId: Long, + @Path("groupId") groupId: Long + ): ApiResponse> + + @GET("agency/{agencyId}/search") + suspend fun searchParts( + @Path("agencyId") agencyId: Long, + @Query("keyword") keyword: String, + @Query("page") page: Int = 0, + @Query("size") size: Int = 20 + ): ApiResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/CategoryDto.kt b/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/CategoryDto.kt new file mode 100644 index 0000000..313b6cb --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/CategoryDto.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.part.data.remote.dto + +data class CategoryDto( + val id: Long, + val code: String, + val name: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/GroupDto.kt b/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/GroupDto.kt new file mode 100644 index 0000000..991e1fe --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/GroupDto.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.part.data.remote.dto + +data class GroupDto( + val id: Long, + val code: String, + val name: String, + val categoryId: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/PartDto.kt b/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/PartDto.kt new file mode 100644 index 0000000..5af2766 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/PartDto.kt @@ -0,0 +1,9 @@ +package com.sampoom.android.feature.part.data.remote.dto + +data class PartDto( + val partId: Long, + val code: String, + val name: String, + val quantity: Long, + val standardCost: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/SearchDataDto.kt b/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/SearchDataDto.kt new file mode 100644 index 0000000..5f29d7e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/data/remote/dto/SearchDataDto.kt @@ -0,0 +1,20 @@ +package com.sampoom.android.feature.part.data.remote.dto + +data class SearchDataDto( + val content: List, + val totalElements: Int, + val totalPages: Int, + val currentPage: Int +) + +data class SearchCategoryDto( + val categoryId: Long, + val categoryName: String, + val groups: List +) + +data class SearchGroupDto( + val groupId: Long, + val groupName: String, + val parts: List +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/data/repository/PartRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/part/data/repository/PartRepositoryImpl.kt new file mode 100644 index 0000000..c9b45e1 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/data/repository/PartRepositoryImpl.kt @@ -0,0 +1,54 @@ +package com.sampoom.android.feature.part.data.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.sampoom.android.core.preferences.AuthPreferences +import com.sampoom.android.feature.part.data.mapper.toModel +import com.sampoom.android.feature.part.data.paging.PartPagingSource +import com.sampoom.android.feature.part.data.remote.api.PartApi +import com.sampoom.android.feature.part.domain.model.CategoryList +import com.sampoom.android.feature.part.domain.model.GroupList +import com.sampoom.android.feature.part.domain.model.PartList +import com.sampoom.android.feature.part.domain.model.SearchResult +import com.sampoom.android.feature.part.domain.repository.PartRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class PartRepositoryImpl @Inject constructor( + private val api: PartApi, + private val authPreferences: AuthPreferences, + private val pagingSourceFactory: PartPagingSource.Factory +) : PartRepository { + override suspend fun getCategoryList(): Result { + return runCatching { + val dto = api.getCategoryList() + val categoryItems = dto.data.map { it.toModel() } + CategoryList(items = categoryItems) + } + } + + override suspend fun getGroupList(categoryId: Long): Result { + return runCatching { + val response = api.getGroupList(categoryId) + val groupItems = response.data.map { it.toModel() } + GroupList(items = groupItems) + } + } + + override suspend fun getPartList(groupId: Long): Result { + return runCatching { + val agencyId = authPreferences.getStoredUser()?.agencyId ?: throw Exception() + val response = api.getPartList(agencyId = agencyId, groupId = groupId) + val partItems = response.data.map { it.toModel() } + PartList(items = partItems) + } + } + + override fun searchParts(keyword: String): Flow> { + return Pager( + config = PagingConfig(pageSize = 20), + pagingSourceFactory = { pagingSourceFactory.create(keyword) } + ).flow + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/di/PartModules.kt b/app/src/main/java/com/sampoom/android/feature/part/di/PartModules.kt new file mode 100644 index 0000000..4d1a08a --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/di/PartModules.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.feature.part.di + +import com.sampoom.android.feature.part.data.remote.api.PartApi +import com.sampoom.android.feature.part.data.repository.PartRepositoryImpl +import com.sampoom.android.feature.part.domain.repository.PartRepository +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 PartBinModule { + @Binds @Singleton + abstract fun bindPartRepository(impl: PartRepositoryImpl): PartRepository +} + +@Module +@InstallIn(SingletonComponent::class) +object PartModule { + @Provides @Singleton + fun providePartApi(retrofit: Retrofit): PartApi = retrofit.create(PartApi::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/model/Category.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/model/Category.kt new file mode 100644 index 0000000..18834d6 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/model/Category.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.part.domain.model + +data class Category( + val id: Long, + val code: String, + val name: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/model/CategoryList.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/model/CategoryList.kt new file mode 100644 index 0000000..95d8248 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/model/CategoryList.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.part.domain.model + +data class CategoryList( + val items: List, + val totalCount: Int = items.size, + val isEmpty: Boolean = items.isEmpty() +) { + companion object Companion { + fun empty() = CategoryList(emptyList()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/model/Group.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/model/Group.kt new file mode 100644 index 0000000..833c004 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/model/Group.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.part.domain.model + +data class Group( + val id: Long, + val code: String, + val name: String, + val categoryId: Long +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/model/GroupList.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/model/GroupList.kt new file mode 100644 index 0000000..34bdd22 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/model/GroupList.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.part.domain.model + +data class GroupList( + val items: List, + val totalCount: Int = items.size, + val isEmpty: Boolean = items.isEmpty() +) { + companion object Companion { + fun empty() = GroupList(emptyList()) + } +} diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/model/Part.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/model/Part.kt new file mode 100644 index 0000000..4899956 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/model/Part.kt @@ -0,0 +1,9 @@ +package com.sampoom.android.feature.part.domain.model + +data class Part( + val partId: Long, + val code: String, + val name: String, + val quantity: Long, + val standardCost: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/model/PartList.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/model/PartList.kt new file mode 100644 index 0000000..74b0891 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/model/PartList.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.part.domain.model + +data class PartList( + val items: List, + val totalCount: Int = items.size, + val isEmpty: Boolean = items.isEmpty() +) { + companion object Companion { + fun empty() = PartList(emptyList()) + } +} diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/model/SearchResult.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/model/SearchResult.kt new file mode 100644 index 0000000..cfa350b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/model/SearchResult.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.part.domain.model + +data class SearchResult( + val part: Part, + val categoryName: String, + val groupName: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/repository/PartRepository.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/repository/PartRepository.kt new file mode 100644 index 0000000..5947b52 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/repository/PartRepository.kt @@ -0,0 +1,15 @@ +package com.sampoom.android.feature.part.domain.repository + +import androidx.paging.PagingData +import com.sampoom.android.feature.part.domain.model.CategoryList +import com.sampoom.android.feature.part.domain.model.GroupList +import com.sampoom.android.feature.part.domain.model.PartList +import com.sampoom.android.feature.part.domain.model.SearchResult +import kotlinx.coroutines.flow.Flow + +interface PartRepository { + suspend fun getCategoryList(): Result + suspend fun getGroupList(categoryId: Long): Result + suspend fun getPartList(groupId: Long): Result + fun searchParts(keyword: String): Flow> +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/GetCategoryUseCase.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/GetCategoryUseCase.kt new file mode 100644 index 0000000..c9aeaa6 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/GetCategoryUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.part.domain.usecase + +import com.sampoom.android.feature.part.domain.model.CategoryList +import com.sampoom.android.feature.part.domain.repository.PartRepository +import javax.inject.Inject + +class GetCategoryUseCase @Inject constructor( + private val repository: PartRepository +) { + suspend operator fun invoke(): Result = repository.getCategoryList() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/GetGroupUseCase.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/GetGroupUseCase.kt new file mode 100644 index 0000000..4d57546 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/GetGroupUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.part.domain.usecase + +import com.sampoom.android.feature.part.domain.model.GroupList +import com.sampoom.android.feature.part.domain.repository.PartRepository +import javax.inject.Inject + +class GetGroupUseCase @Inject constructor( + private val repository: PartRepository +) { + suspend operator fun invoke(categoryId: Long): Result = repository.getGroupList(categoryId) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/GetPartUseCase.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/GetPartUseCase.kt new file mode 100644 index 0000000..211c2ac --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/GetPartUseCase.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.part.domain.usecase + +import com.sampoom.android.feature.part.domain.model.PartList +import com.sampoom.android.feature.part.domain.repository.PartRepository +import javax.inject.Inject + +class GetPartUseCase @Inject constructor( + private val repository: PartRepository +) { + suspend operator fun invoke(groupId: Long): Result = repository.getPartList(groupId) +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/SearchPartsUseCase.kt b/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/SearchPartsUseCase.kt new file mode 100644 index 0000000..9dfe67c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/domain/usecase/SearchPartsUseCase.kt @@ -0,0 +1,14 @@ +package com.sampoom.android.feature.part.domain.usecase + +import androidx.paging.PagingData +import com.sampoom.android.feature.part.domain.model.SearchResult +import com.sampoom.android.feature.part.domain.repository.PartRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class SearchPartsUseCase @Inject constructor( + private val repository: PartRepository +) { + operator fun invoke(keyword: String): Flow> = repository.searchParts(keyword) + +} \ No newline at end of file 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 new file mode 100644 index 0000000..2afbcfa --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailBottomSheet.kt @@ -0,0 +1,285 @@ +package com.sampoom.android.feature.part.ui + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +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.ButtonSize +import com.sampoom.android.core.ui.component.ButtonVariant +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.core.util.formatWon +import com.sampoom.android.feature.part.domain.model.Part + +@Composable +fun PartDetailBottomSheet( + part: Part, + onDismiss: () -> Unit, + viewModel: PartDetailViewModel = hiltViewModel() +) { + val errorLabel = stringResource(R.string.common_error) + val addOutboundLabel = stringResource(R.string.outbound_toast_success) + val addCartLabel = stringResource(R.string.cart_toast_success) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showOutboundDialog by remember { mutableStateOf(false) } + var showCartDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.clearStatus() + } + + LaunchedEffect(errorLabel, addOutboundLabel, addCartLabel) { + viewModel.bindLabel(errorLabel, addOutboundLabel, addCartLabel) + } + + LaunchedEffect(part.partId) { + viewModel.onEvent(PartDetailUiEvent.Initialize(part)) + } + + // 성공 시 Toast 표시 후 다이얼로그 닫기 + LaunchedEffect(uiState.isOutboundSuccess) { + if (uiState.isOutboundSuccess) { + viewModel.clearStatus() + onDismiss() + } + } + + // 성공 시 Toast 표시 후 다이얼로그 닫기 + LaunchedEffect(uiState.isCartSuccess) { + if (uiState.isCartSuccess) { + viewModel.clearStatus() + onDismiss() + } + } + + LaunchedEffect(uiState.updateError) { + if (uiState.updateError != null) { + viewModel.clearStatus() + onDismiss() + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1F) + ) { + Text( + text = part.name, + style = MaterialTheme.typography.titleMedium, + color = textColor(), + modifier = Modifier.fillMaxWidth() + ) + Text( + text = part.code, + style = MaterialTheme.typography.bodySmall, + color = textSecondaryColor(), + modifier = Modifier.fillMaxWidth() + ) + } + + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = formatWon(part.standardCost), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End, + color = textColor() + ) + Text( + stringResource(R.string.part_current_quantity) + + part.quantity.toString() + + stringResource(R.string.common_EA) + ) + } + + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(R.string.part_title_quantity), Modifier.weight(1F)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CommonButton( + variant = ButtonVariant.Neutral, + size = ButtonSize.Large, + onClick = { viewModel.onEvent(PartDetailUiEvent.DecreaseQuantity) }, + enabled = uiState.quantity > 1 && !uiState.isUpdating + ) { + Text( + text = stringResource(R.string.part_minus), + style = MaterialTheme.typography.titleLarge + ) + } + OutlinedTextField( + value = uiState.quantity.toString(), + onValueChange = { newValue -> + when { + newValue.isEmpty() -> viewModel.onEvent(PartDetailUiEvent.SetQuantity(1)) + newValue == "0" -> viewModel.onEvent(PartDetailUiEvent.SetQuantity(1)) + else -> { + val newQuantity = newValue.toLongOrNull() + if (newQuantity != null && newQuantity > 0) viewModel.onEvent( + PartDetailUiEvent.SetQuantity(newQuantity) + ) + else viewModel.onEvent(PartDetailUiEvent.SetQuantity(1)) + } + } + }, + modifier = Modifier.width(100.dp), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = MaterialTheme.typography.titleMedium.copy(textAlign = TextAlign.Center), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + errorBorderColor = Color.Transparent + ) + ) + CommonButton( + variant = ButtonVariant.Neutral, + size = ButtonSize.Large, + onClick = { viewModel.onEvent(PartDetailUiEvent.IncreaseQuantity) }, + enabled = !uiState.isUpdating + ) { + Text( + text = stringResource(R.string.part_plus), + style = MaterialTheme.typography.titleLarge + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + CommonButton( + modifier = Modifier.weight(1F), + variant = ButtonVariant.Error, + size = ButtonSize.Large, + leadingIcon = { + Icon( + painterResource(R.drawable.outbound), + contentDescription = null + ) + }, + onClick = { showOutboundDialog = true } + ) { Text(stringResource(R.string.part_add_delivery)) } + Spacer(Modifier.width(8.dp)) + CommonButton( + modifier = Modifier.weight(1F), + variant = ButtonVariant.Primary, + size = ButtonSize.Large, + leadingIcon = { Icon(painterResource(R.drawable.cart), contentDescription = null) }, + onClick = { showCartDialog = true } + ) { Text(stringResource(R.string.part_add_cart)) } + } + } + + // 확인 다이얼로그 + if (showOutboundDialog) { + AlertDialog( + onDismissRequest = { showOutboundDialog = false }, + text = { Text(stringResource(R.string.outbound_dialog_text)) }, + confirmButton = { + TextButton( + onClick = { + showOutboundDialog = false + viewModel.onEvent( + PartDetailUiEvent.AddToOutbound( + partId = part.partId, + quantity = uiState.quantity + ) + ) + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showOutboundDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } + + if (showCartDialog) { + AlertDialog( + onDismissRequest = { showCartDialog = false }, + text = { Text(stringResource(R.string.cart_dialog_text)) }, + confirmButton = { + TextButton( + onClick = { + showCartDialog = false + viewModel.onEvent( + PartDetailUiEvent.AddToCart( + partId = part.partId, + quantity = uiState.quantity + ) + ) + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showCartDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailUiEvent.kt new file mode 100644 index 0000000..f72614b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailUiEvent.kt @@ -0,0 +1,13 @@ +package com.sampoom.android.feature.part.ui + +import com.sampoom.android.feature.part.domain.model.Part + +sealed interface PartDetailUiEvent { + data class Initialize(val part: Part) : PartDetailUiEvent + object IncreaseQuantity : PartDetailUiEvent + object DecreaseQuantity : PartDetailUiEvent + data class SetQuantity(val quantity: Long) : PartDetailUiEvent + data class AddToOutbound(val partId: Long, val quantity: Long) : PartDetailUiEvent + data class AddToCart(val partId: Long, val quantity: Long) : PartDetailUiEvent + object Dismiss : PartDetailUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailUiState.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailUiState.kt new file mode 100644 index 0000000..ed51a5d --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailUiState.kt @@ -0,0 +1,12 @@ +package com.sampoom.android.feature.part.ui + +import com.sampoom.android.feature.part.domain.model.Part + +data class PartDetailUiState( + val part: Part? = null, + val quantity: Long = 1, + val isUpdating: Boolean = false, + val updateError: String? = null, + val isOutboundSuccess: Boolean = false, + val isCartSuccess: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailViewModel.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailViewModel.kt new file mode 100644 index 0000000..4213ca5 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartDetailViewModel.kt @@ -0,0 +1,139 @@ +package com.sampoom.android.feature.part.ui + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.core.network.serverMessageOrNull +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.cart.domain.usecase.AddCartUseCase +import com.sampoom.android.feature.outbound.domain.usecase.AddOutboundUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +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 PartDetailViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + private val addOutboundUseCase: AddOutboundUseCase, + private val addCartUseCase: AddCartUseCase +) : ViewModel() { + + private companion object { + private const val TAG = "OutboundDetailViewModel" + } + + private val _uiState = MutableStateFlow(PartDetailUiState()) + val uiState: StateFlow = _uiState + + private var errorLabel: String = "" + private var addOutboundLabel: String = "" + private var addCartLabel: String = "" + + fun bindLabel(error: String, outbound: String, cart: String) { + errorLabel = error + addOutboundLabel = outbound + addCartLabel = cart + } + + fun onEvent(event: PartDetailUiEvent) { + when (event) { + is PartDetailUiEvent.Initialize -> { + _uiState.update { + it.copy( + part = event.part, + quantity = 1, + isUpdating = false, + isOutboundSuccess = false, + isCartSuccess = false + ) + } + } + is PartDetailUiEvent.IncreaseQuantity -> { + val currentQuantity = _uiState.value.quantity + _uiState.update { it.copy(quantity = currentQuantity + 1) } + } + is PartDetailUiEvent.DecreaseQuantity -> { + val currentQuantity = _uiState.value.quantity + _uiState.update { it.copy(quantity = maxOf(1L, currentQuantity - 1)) } + } + is PartDetailUiEvent.SetQuantity -> { + if (event.quantity > 0) { + _uiState.update { it.copy(quantity = event.quantity) } + } + } + is PartDetailUiEvent.AddToOutbound -> { + val part = _uiState.value.part + val quantity = _uiState.value.quantity + if (part != null) { + addToOutbound(part.partId, quantity) + } + } + is PartDetailUiEvent.AddToCart -> { + val part = _uiState.value.part + val quantity = _uiState.value.quantity + if (part != null) { + addToCart(part.partId, quantity) + } + } + is PartDetailUiEvent.Dismiss -> { + _uiState.update { + it.copy( + part = null, + quantity = 1 + ) + } + } + } + } + + private fun addToOutbound(partId: Long, quantity: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isUpdating = true, updateError = null) } + + addOutboundUseCase(partId, quantity) + .onSuccess { + messageHandler.showMessage(message= addOutboundLabel, isError = false) + _uiState.update { it.copy(isUpdating = false, isOutboundSuccess = true) } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy(isUpdating = false, updateError = error) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + private fun addToCart(partId: Long, quantity: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isUpdating = true, updateError = null) } + + addCartUseCase(partId, quantity) + .onSuccess { + messageHandler.showMessage(message= addCartLabel, isError = false) + _uiState.update { it.copy(isUpdating = false, isCartSuccess = true) } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy(isUpdating = false, updateError = error) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } + + fun clearStatus() { + _uiState.update { it.copy(isOutboundSuccess = false, isCartSuccess = false, updateError = null) } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..cadb1ae --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListScreen.kt @@ -0,0 +1,262 @@ +package com.sampoom.android.feature.part.ui + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.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 +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +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.theme.backgroundCardColor +import com.sampoom.android.core.ui.theme.disableColor +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.core.ui.theme.textSecondaryColor +import com.sampoom.android.core.util.formatWon +import com.sampoom.android.feature.part.domain.model.Part +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PartListScreen( + onNavigateBack: () -> Unit = {}, + navController: NavHostController = rememberNavController(), + viewModel: PartListViewModel = hiltViewModel() +) { + val coroutineScope = rememberCoroutineScope() + val groupName by navController.previousBackStackEntry?.savedStateHandle?.getStateFlow( + "groupName", + null + )?.collectAsState() ?: remember { mutableStateOf(null) } + + val errorLabel = stringResource(R.string.common_error) + + LaunchedEffect(errorLabel) { + viewModel.bindLabel(errorLabel) + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() + + // ModalBottomSheet 상태 관리 + val sheetState = rememberModalBottomSheetState(true) + val selectedPart = uiState.selectedPart + + // selectedPart가 변경되면 시트 표시/숨김 + LaunchedEffect(selectedPart) { + if (selectedPart != null && !sheetState.isVisible) { + sheetState.show() + } else if (selectedPart == null && sheetState.isVisible) { + sheetState.hide() + } + } + + PullToRefreshBox( + isRefreshing = false, + 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 + ) + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(groupName ?: stringResource(R.string.part_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.nav_back) + ) + } + } + ) + } + ) { innerPadding -> + when { + uiState.partListLoading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + uiState.partListError != null -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + ErrorContent( + onRetry = { viewModel.onEvent(PartListUiEvent.RetryPartList) }, + modifier = Modifier.height(200.dp) + ) + } + } + + 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)) + } + ) + } + } + } + } + } + } + + if (selectedPart != null) { + uiState.selectedPart?.let { selectedPart -> + ModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + sheetState.hide() + viewModel.onEvent(PartListUiEvent.DismissBottomSheet) + } + }, + sheetState = sheetState + ) { + PartDetailBottomSheet( + part = selectedPart, + onDismiss = { + coroutineScope.launch { + sheetState.hide() + viewModel.onEvent(PartListUiEvent.DismissBottomSheet) + } + } + ) + } + } + } +} + +@Composable +private fun PartListItemCard( + part: Part, + onClick: () -> Unit +) { + Card( + onClick = { onClick() }, + 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, + color = textColor(), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = part.code, + color = textSecondaryColor(), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Light + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatWon(part.standardCost), + color = textColor(), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = part.quantity.toString(), + color = textColor(), + style = MaterialTheme.typography.titleMedium + ) + } + + Icon( + painterResource(R.drawable.chevron_right), + contentDescription = stringResource(R.string.common_detail), + tint = disableColor() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartListUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListUiEvent.kt new file mode 100644 index 0000000..a99d6d5 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListUiEvent.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.part.ui + +import com.sampoom.android.feature.part.domain.model.Part + +sealed interface PartListUiEvent { + object LoadPartList : PartListUiEvent + object RetryPartList : PartListUiEvent + data class ShowBottomSheet(val part: Part) : PartListUiEvent + object DismissBottomSheet : PartListUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartListUiState.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListUiState.kt new file mode 100644 index 0000000..10f4cbb --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListUiState.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.part.ui + +import com.sampoom.android.feature.part.domain.model.Part + +data class PartListUiState( + val partList: List = emptyList(), + val partListLoading: Boolean = false, + val partListError: String? = null, + val selectedPart: Part? = null +) 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 new file mode 100644 index 0000000..811fc25 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartListViewModel.kt @@ -0,0 +1,85 @@ +package com.sampoom.android.feature.part.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.core.util.GlobalMessageHandler +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 +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PartListViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + private val getPartListUseCase: GetPartUseCase, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private companion object { + private const val TAG = "PartListViewModel" + } + + private val _uiState = MutableStateFlow(PartListUiState()) + val uiState: StateFlow = _uiState + + // Navigation 인자 로드 + private val agencyId: Long = savedStateHandle.get("agencyId") ?: 0L + private val groupId: Long = savedStateHandle.get("groupId") ?: 0L + + private var errorLabel: String = "" + + fun bindLabel(error: String) { + errorLabel = error + } + + init { + if (groupId > 0L) loadPartList(groupId) + else _uiState.update { it.copy(partListError = errorLabel) } + } + + fun onEvent(event: PartListUiEvent) { + when (event) { + is PartListUiEvent.LoadPartList -> loadPartList(groupId) + is PartListUiEvent.RetryPartList -> loadPartList(groupId) + is PartListUiEvent.ShowBottomSheet -> _uiState.update { it.copy(selectedPart = event.part) } + is PartListUiEvent.DismissBottomSheet -> _uiState.update { it.copy(selectedPart = null) } + } + } + + private fun loadPartList(groupId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(partListLoading = true, partListError = null) } + + getPartListUseCase(groupId) + .onSuccess { partList -> + _uiState.update { + it.copy( + partList = partList.items, + partListLoading = false, + partListError = null + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + partListLoading = false, + partListError = error + ) + } + } + Log.d(TAG, "submit: ${_uiState.value}") + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..2791fed --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartScreen.kt @@ -0,0 +1,654 @@ +package com.sampoom.android.feature.part.ui + +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.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.ModalBottomSheet +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +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.theme.FailRed +import com.sampoom.android.core.ui.theme.Main500 +import com.sampoom.android.core.ui.theme.White +import com.sampoom.android.core.ui.theme.backgroundCardColor +import com.sampoom.android.core.ui.theme.backgroundColor +import com.sampoom.android.core.ui.theme.disableColor +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.core.ui.theme.textSecondaryColor +import com.sampoom.android.core.util.formatWon +import com.sampoom.android.feature.part.domain.model.Category +import com.sampoom.android.feature.part.domain.model.Group +import com.sampoom.android.feature.part.domain.model.Part +import com.sampoom.android.feature.part.domain.model.SearchResult +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PartScreen( + onNavigateBack: () -> Unit = {}, + onNavigatePartList: (Group) -> Unit, + viewModel: PartViewModel = hiltViewModel(), + searchViewModel: PartListViewModel = hiltViewModel() +) { + val errorLabel = stringResource(R.string.common_error) + + LaunchedEffect(errorLabel) { + viewModel.bindLabel(errorLabel) + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val searchUiState by searchViewModel.uiState.collectAsStateWithLifecycle() + val searchResultsPaged = viewModel.searchResult.collectAsLazyPagingItems() + var textFieldState by remember { mutableStateOf(TextFieldValue("")) } + var expanded by rememberSaveable { mutableStateOf(false) } + + // 자동 검색을 위한 LaunchedEffect + var searchJob by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + // ModalBottomSheet 상태 관리 + val sheetState = rememberModalBottomSheetState() + val selectedPart = searchUiState.selectedPart + + // selectedPart가 변경되면 시트 표시/숨김 + LaunchedEffect(selectedPart) { + if (selectedPart != null && !sheetState.isVisible) { + sheetState.show() + } else if (selectedPart == null && sheetState.isVisible) { + sheetState.hide() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + ) { + SearchBar( + modifier = Modifier.align(Alignment.TopCenter), + inputField = { + SearchBarDefaults.InputField( + query = textFieldState.text, + onQueryChange = { newText -> + textFieldState = textFieldState.copy(newText) + + // 기존 검색 작업 취소 + searchJob?.cancel() + + if (newText.isNotBlank()) { + // 500ms 지연 후 검색 실행 + searchJob = coroutineScope.launch { + delay(500) + if (newText.isNotBlank()) { + viewModel.onEvent(PartUiEvent.Search(newText)) + } + } + } else { + // 검색어가 비어있으면 검색 결과 초기화 + viewModel.onEvent(PartUiEvent.Search("")) + } + }, + onSearch = { + searchJob?.cancel() + viewModel.onEvent(PartUiEvent.Search(textFieldState.text)) + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + placeholder = { Text(stringResource(R.string.part_title_search)) }, + leadingIcon = { + if (expanded) { + IconButton(onClick = { expanded = false }) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.nav_back) + ) + } + } else { + IconButton(onClick = { onNavigateBack() }) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.nav_back) + ) + } + } + }, + trailingIcon = { + if (textFieldState.text.isNotEmpty()) { + IconButton( + onClick = { + searchJob?.cancel() + textFieldState = textFieldState.copy("") + viewModel.onEvent(PartUiEvent.Search("")) + } + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.part_search_clear) + ) + } + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = backgroundColor(), + unfocusedContainerColor = backgroundCardColor(), + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = textSecondaryColor().copy(0.3f), + focusedTrailingIconColor = textSecondaryColor(), + unfocusedTrailingIconColor = textSecondaryColor() + ) + ) + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + colors = SearchBarDefaults.colors( + containerColor = backgroundColor(), + dividerColor = Color.Transparent + ) + ) { + when { + textFieldState.text.isBlank() -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.part_search_description), + color = textSecondaryColor(), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + searchResultsPaged.loadState.refresh is LoadState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + searchResultsPaged.loadState.refresh is LoadState.Error -> { + ErrorContent( + onRetry = { viewModel.onEvent(PartUiEvent.Search(textFieldState.text)) }, + modifier = Modifier.fillMaxSize() + ) + } + + searchResultsPaged.itemCount == 0 && textFieldState.text.isNotBlank() -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + EmptyContent( + message = stringResource(R.string.part_search_empty) + ) + } + } + + else -> { + SearchResultsList( + searchResults = searchResultsPaged, + onPartClick = { part -> + searchViewModel.onEvent(PartListUiEvent.ShowBottomSheet(part)) + } + ) + } + } + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + Spacer(Modifier.height(100.dp)) + + // Category 선택 제목 + Text( + text = stringResource(R.string.part_title_category), + style = MaterialTheme.typography.titleMedium, + color = textColor(), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 16.dp) + ) + + // Category 섹션 + when { + uiState.categoryLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + uiState.categoryError != null -> { + ErrorContent( + onRetry = { viewModel.onEvent(PartUiEvent.RetryCategories) }, + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + ) + } + + uiState.categoryList.isEmpty() -> { + EmptyContent( + message = stringResource(R.string.part_empty_category), + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + ) + } + + else -> { + // 2x3 그리드로 카테고리 배치 + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + uiState.categoryList.chunked(3).forEach { categoryChunk -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + categoryChunk.forEach { category -> + CategoryItem( + category = category, + isSelected = category.id == uiState.selectedCategory?.id, + onClick = { + viewModel.onEvent( + PartUiEvent.CategorySelected( + category + ) + ) + }, + modifier = Modifier.weight(1f) + ) + } + + // 3개 미만인 경우 빈 공간 채우기 + repeat(3 - categoryChunk.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + } + + Spacer(Modifier.height(24.dp)) + + // 그룹 리스트 섹션 + if (uiState.selectedCategory == null) { + // 초기 상태: 카테고리 선택 안내 + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = textSecondaryColor() + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.part_select_category), + style = MaterialTheme.typography.bodyMedium, + color = textSecondaryColor() + ) + } + } else { + // 그룹 선택 제목 + Text( + text = stringResource(R.string.part_title_group), + style = MaterialTheme.typography.titleMedium, + color = textColor(), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 16.dp) + ) + + // 그룹 리스트 + when { + uiState.groupLoading -> { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + uiState.groupError != null -> { + ErrorContent( + onRetry = { viewModel.onEvent(PartUiEvent.RetryGroups) }, + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + ) + } + + uiState.groupList.isEmpty() -> { + EmptyContent( + message = stringResource(R.string.part_empty_group), + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + ) + } + + else -> { + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + uiState.groupList.forEach { group -> + PartItemCard( + group = group, + onClick = { + onNavigatePartList(group) + } + ) + } + } + } + } + } + + Spacer(Modifier.height(100.dp)) + } + } + + if (selectedPart != null) { + searchUiState.selectedPart?.let { selectedPart -> + ModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + sheetState.hide() + searchViewModel.onEvent(PartListUiEvent.DismissBottomSheet) + } + }, + sheetState = sheetState + ) { + PartDetailBottomSheet( + part = selectedPart, + onDismiss = { + coroutineScope.launch { + sheetState.hide() + searchViewModel.onEvent(PartListUiEvent.DismissBottomSheet) + } + } + ) + } + } + } +} + +// Category 아이템 +@Composable +fun CategoryItem( + category: Category, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = { onClick() }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) Main500 else backgroundCardColor() + ), + modifier = modifier.height(100.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = resourceMapper(category.code)), + contentDescription = category.name, + modifier = Modifier.size(32.dp), + tint = if (isSelected) White else textColor() + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = category.name, + style = MaterialTheme.typography.bodySmall, + color = if (isSelected) White else textColor() + ) + } + } +} + +private fun resourceMapper(code: String): Int { + return when (code) { + "ENG" -> R.drawable.engine + "TRN" -> R.drawable.transmission + "CHS" -> R.drawable.chassis + "BDY" -> R.drawable.body + "TRM" -> R.drawable.trim + "ELE" -> R.drawable.electric + else -> R.drawable.parts + } +} + +@Composable +private fun PartItemCard( + group: Group, + onClick: () -> Unit +) { + Card( + onClick = { onClick() }, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundCardColor()), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = group.name, + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + + Icon( + painterResource(R.drawable.chevron_right), + contentDescription = stringResource(R.string.common_detail), + tint = disableColor() + ) + } + } +} + +@Composable +fun SearchResultsList( + searchResults: LazyPagingItems, + onPartClick: (Part) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp) + ) { + items( + count = searchResults.itemCount, + key = searchResults.itemKey { it.part.partId } + ) { index -> + val partWithContext = searchResults[index] + if (partWithContext != null) { + SearchPartItem( + part = partWithContext.part, + categoryName = partWithContext.categoryName, + groupName = partWithContext.groupName, + onClick = { onPartClick(partWithContext.part) } + ) + } + } + + item { + when (searchResults.loadState.append) { + is LoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is LoadState.Error -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.common_error), + color = FailRed + ) + } + } + + else -> {} + } + } + + item { Spacer(Modifier.height(50.dp))} + } +} + +@Composable +private fun SearchPartItem( + part: Part, + categoryName: String, + groupName: String, + onClick: () -> Unit +) { + Card( + onClick = { onClick() }, + 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 = "$categoryName > $groupName", + style = MaterialTheme.typography.bodySmall, + color = textSecondaryColor() + ) + + Text( + text = part.name, + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + + Text( + text = part.code, + style = MaterialTheme.typography.bodySmall, + color = textSecondaryColor(), + fontWeight = FontWeight.Light + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatWon(part.standardCost), + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + + Text( + text = "${part.quantity}", + style = MaterialTheme.typography.bodyMedium, + color = textColor() + ) + } + + Icon( + painterResource(R.drawable.chevron_right), + contentDescription = stringResource(R.string.common_detail), + tint = disableColor() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartUiEvent.kt new file mode 100644 index 0000000..271f828 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartUiEvent.kt @@ -0,0 +1,12 @@ +package com.sampoom.android.feature.part.ui + +import com.sampoom.android.feature.part.domain.model.Category + +sealed interface PartUiEvent { + object LoadCategories : PartUiEvent + data class CategorySelected(val category: Category) : PartUiEvent + object RetryCategories : PartUiEvent + object RetryGroups : PartUiEvent + data class Search(val keyword: String) : PartUiEvent + data class SetKeyword(val keyword: String) : PartUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/part/ui/PartUiState.kt b/app/src/main/java/com/sampoom/android/feature/part/ui/PartUiState.kt new file mode 100644 index 0000000..19d8842 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartUiState.kt @@ -0,0 +1,23 @@ +package com.sampoom.android.feature.part.ui + +import com.sampoom.android.feature.part.domain.model.Category +import com.sampoom.android.feature.part.domain.model.Group +import com.sampoom.android.feature.part.domain.model.SearchResult + +data class PartUiState( + // Part + val groupList: List = emptyList(), + val groupLoading: Boolean = false, + val groupError: String? = null, + + // 선택된 Category + val selectedCategory: Category? = null, + + // Category + val categoryList: List = emptyList(), + val categoryLoading: Boolean = false, + val categoryError: String? = null, + + // Search + val keyword: String? = null +) \ No newline at end of file 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 new file mode 100644 index 0000000..7ecbd5e --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/part/ui/PartViewModel.kt @@ -0,0 +1,165 @@ +package com.sampoom.android.feature.part.ui + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sampoom.android.core.network.serverMessageOrNull +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.part.domain.model.Category +import com.sampoom.android.feature.part.domain.model.SearchResult +import com.sampoom.android.feature.part.domain.usecase.GetCategoryUseCase +import com.sampoom.android.feature.part.domain.usecase.GetGroupUseCase +import com.sampoom.android.feature.part.domain.usecase.SearchPartsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PartViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + private val getCategoryUseCase: GetCategoryUseCase, + private val getGroupUseCase: GetGroupUseCase, + private val searchPartsUseCase: SearchPartsUseCase +) : ViewModel() { + + private companion object { + private const val TAG = "PartViewModel" + } + + private val _uiState = MutableStateFlow(PartUiState()) + val uiState: StateFlow = _uiState + + private var groupLoadJob: Job? = null + private var errorLabel: String = "" + + fun bindLabel(error: String) { + errorLabel = error + } + + init { + loadCategory() + } + + private val _searchKeyword = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + val searchResult: Flow> = _searchKeyword + .debounce(300) + .distinctUntilChanged() + .flatMapLatest { keyword -> + if (keyword.isNullOrBlank()) { + flowOf(PagingData.empty()) + } else { + searchPartsUseCase(keyword) + } + } + .cachedIn(viewModelScope) + + fun onEvent(event: PartUiEvent) { + when (event) { + is PartUiEvent.LoadCategories -> loadCategory() + is PartUiEvent.CategorySelected -> selectCategory(event.category) + is PartUiEvent.RetryCategories -> loadCategory() + is PartUiEvent.RetryGroups -> loadGroup() + is PartUiEvent.Search -> { + _searchKeyword.value = event.keyword + _uiState.update { + it.copy(keyword = event.keyword) + } + } + is PartUiEvent.SetKeyword -> _uiState.update { it.copy(keyword = event.keyword) } + } + } + + private fun loadCategory() { + viewModelScope.launch { + _uiState.update { it.copy(categoryLoading = true, categoryError = null) } + + getCategoryUseCase() + .onSuccess { categoryList -> + _uiState.update { + it.copy( + categoryList = categoryList.items, + categoryLoading = false, + categoryError = null + ) + } + } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + + _uiState.update { + it.copy( + categoryLoading = false, + categoryError = error + ) + } + } + Log.d(TAG, "loadCategory: ${_uiState.value}") + } + } + + private fun selectCategory(category: Category) { + viewModelScope.launch { + _uiState.update { it.copy(selectedCategory = category) } + groupLoadJob?.cancel() // 기존 그룹 로드 취소 후 새 요청 + loadGroup(category.id) + } + } + + private fun loadGroup(categoryId: Long) { + groupLoadJob?.cancel() + groupLoadJob = viewModelScope.launch { + _uiState.update { it.copy(groupLoading = true, groupError = null) } + + getGroupUseCase(categoryId) + .onSuccess { groupList -> + 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() + val error = backendMessage ?: (throwable.message ?: errorLabel) + messageHandler.showMessage(message = error, isError = true) + if (_uiState.value.selectedCategory?.id != categoryId) return@launch + + _uiState.update { + it.copy( + groupLoading = false, + groupError = error + ) + } + } + Log.d(TAG, "loadGroup: ${_uiState.value}") + } + } + + private fun loadGroup() { + val selectedCategory = _uiState.value.selectedCategory + if (selectedCategory != null) { + loadGroup(selectedCategory.id) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/sample/data/local/database/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/data/local/database/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/data/local/preferences/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/data/local/preferences/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/data/mapper/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/data/mapper/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/data/remote/api/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/data/remote/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/data/remote/dto/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/data/remote/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/data/repository/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/data/repository/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/di/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/di/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/domain/model/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/domain/model/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/domain/repository/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/domain/repository/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/domain/usecase/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/domain/usecase/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/sample/ui/.gitkeep b/app/src/main/java/com/sampoom/android/feature/sample/ui/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingScreen.kt b/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingScreen.kt new file mode 100644 index 0000000..46b47e0 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingScreen.kt @@ -0,0 +1,260 @@ +package com.sampoom.android.feature.setting.ui + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.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.theme.backgroundCardColor +import com.sampoom.android.core.ui.theme.disableColor +import com.sampoom.android.core.ui.theme.textColor +import com.sampoom.android.core.ui.theme.textSecondaryColor +import com.sampoom.android.core.util.formatDate +import com.sampoom.android.core.util.positionToKorean +import com.sampoom.android.feature.auth.domain.model.User + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingScreen( + viewModel: SettingViewModel = hiltViewModel(), + onNavigateBack: () -> Unit = {}, + onLogoutClick: () -> Unit = {} +) { + val errorLabel = stringResource(R.string.common_error) + val nameLabel = stringResource(R.string.signup_title_name) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val user by viewModel.user.collectAsStateWithLifecycle() + val pullRefreshState = rememberPullToRefreshState() + var showLogoutDialog by remember { mutableStateOf(false) } + + LaunchedEffect(errorLabel, nameLabel) { + viewModel.bindLabel(errorLabel, nameLabel) + } + + LaunchedEffect(uiState.profileChangeSuccess) { + if (uiState.profileChangeSuccess) { + viewModel.clearSuccess() + viewModel.onEvent(SettingUiEvent.LoadProfile) + } + } + + PullToRefreshBox( + isRefreshing = false, + onRefresh = { viewModel.onEvent(SettingUiEvent.LoadProfile) }, + state = pullRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = uiState.loading, + containerColor = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.onPrimaryContainer, + state = pullRefreshState + ) + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.setting_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.nav_back) + ) + } + } + ) + } + ) { innerPadding -> + LazyColumn( + Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + ) { + item { + UserSection( + user = user, + modifier = Modifier + ) + } + item { + SettingSection( + onEditProfileClick = { }, + onLogoutClick = { showLogoutDialog = true } + ) + } + } + } + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + text = { Text(stringResource(R.string.setting_dialog_logout)) }, + confirmButton = { + TextButton( + onClick = { + showLogoutDialog = false + onLogoutClick() + } + ) { + Text(stringResource(R.string.common_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showLogoutDialog = false } + ) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } +} + +@Composable +fun UserSection( + user: User?, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.Start + ) { + Text( + text = user?.userName ?: "", + style = MaterialTheme.typography.headlineLarge, + color = textColor() + ) + Text( + text = positionToKorean(user?.position), + style = MaterialTheme.typography.bodyMedium, + color = textSecondaryColor() + ) + } + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.End + ) { + Text( + text = user?.email ?: "", + style = MaterialTheme.typography.bodyMedium, + color = textSecondaryColor() + ) + user?.startedAt?.takeIf { it.isNotBlank() }?.let { startedAt -> + Text( + text = formatDate(startedAt), + style = MaterialTheme.typography.bodyMedium, + color = textSecondaryColor() + ) + } + user?.endedAt?.takeIf { it.isNotBlank() }?.let { endedAt -> + Text( + text = formatDate(endedAt), + style = MaterialTheme.typography.bodyMedium, + color = textSecondaryColor() + ) + } + } + } +} + +@Composable +private fun SettingSection( + onEditProfileClick: () -> Unit, + onLogoutClick: () -> Unit +) { + Card( + onClick = { onEditProfileClick() }, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundCardColor()), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.setting_edit_profile), + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + + Icon( + painterResource(R.drawable.chevron_right), + contentDescription = stringResource(R.string.common_detail), + tint = disableColor() + ) + } + } + + Spacer(Modifier.height(8.dp)) + + Card( + onClick = { onLogoutClick() }, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundCardColor()), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.setting_logout), + style = MaterialTheme.typography.titleMedium, + color = textColor() + ) + + Icon( + painterResource(R.drawable.chevron_right), + contentDescription = stringResource(R.string.common_detail), + tint = disableColor() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingUiEvent.kt new file mode 100644 index 0000000..4845ecd --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingUiEvent.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.setting.ui + +sealed interface SettingUiEvent { + object LoadProfile : SettingUiEvent + data class NameChanged(val userName: String) : SettingUiEvent + object EditProfile : SettingUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingUiState.kt b/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingUiState.kt new file mode 100644 index 0000000..caad258 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingUiState.kt @@ -0,0 +1,17 @@ +package com.sampoom.android.feature.setting.ui + +import com.sampoom.android.feature.auth.domain.model.User + +data class SettingUiState( + val profile: User? = null, + val userName: String = "", + val userNameError: String? = null, + + val loading: Boolean = false, + val error: Boolean = false, + val profileChangeSuccess: Boolean = false, + val logoutSuccess: Boolean = false +) { + val isValid: Boolean + get() = userName.isNotBlank() && userNameError == null +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingViewModel.kt b/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingViewModel.kt new file mode 100644 index 0000000..2cab7bc --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/setting/ui/SettingViewModel.kt @@ -0,0 +1,89 @@ +package com.sampoom.android.feature.setting.ui + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.core.util.GlobalMessageHandler +import com.sampoom.android.feature.auth.domain.AuthValidator +import com.sampoom.android.feature.auth.domain.ValidationResult +import com.sampoom.android.feature.auth.domain.model.User +import com.sampoom.android.feature.auth.domain.usecase.GetStoredUserUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +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 SettingViewModel @Inject constructor( + private val messageHandler: GlobalMessageHandler, + private val application: Application, + private val getStoredUserUseCase: GetStoredUserUseCase +) : ViewModel() { + + private companion object { + private const val TAG = "SettingViewModel" + } + + private val _uiState = MutableStateFlow(SettingUiState()) + val uiState: StateFlow = _uiState + + private val _user = MutableStateFlow(null) + val user: StateFlow = _user + + private var nameLabel: String = "" + private var errorLabel: String = "" + + fun bindLabel(name: String, error: String) { + nameLabel = name + errorLabel = error + } + + init { + viewModelScope.launch { + _user.value = getStoredUserUseCase() + } + } + + fun onEvent(event: SettingUiEvent) { + when (event) { + is SettingUiEvent.LoadProfile -> {} + is SettingUiEvent.NameChanged -> { + _uiState.value = _uiState.value.copy(userName = event.userName) + validateName() + } + is SettingUiEvent.EditProfile -> editProfile() + } + } + + private fun validateName() { + val result = AuthValidator.validateNotEmpty(_uiState.value.userName, nameLabel) + _uiState.value = _uiState.value.copy( + userNameError = result.toErrorMessage() + ) + } + + private fun ValidationResult.toErrorMessage(): String? { + return when (this) { + is ValidationResult.Error -> application.getString(messageResId) + is ValidationResult.ErrorWithArgs -> application.getString(messageResId, args) + ValidationResult.Success -> null + } + } + + private fun editProfile() = viewModelScope.launch { + validateName() + + if (!_uiState.value.isValid) return@launch + + val s = _uiState.value + _uiState.update { it.copy(loading = true) } + + // TODO : Edit Profile 연동 + } + + fun clearSuccess() { + _uiState.update { it.copy(profileChangeSuccess = false, logoutSuccess = false) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/ui/theme/Color.kt b/app/src/main/java/com/sampoom/android/ui/theme/Color.kt deleted file mode 100644 index c7ef588..0000000 --- a/app/src/main/java/com/sampoom/android/ui/theme/Color.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.sampoom.android.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/ui/theme/Theme.kt b/app/src/main/java/com/sampoom/android/ui/theme/Theme.kt deleted file mode 100644 index cd4d75c..0000000 --- a/app/src/main/java/com/sampoom/android/ui/theme/Theme.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.sampoom.android.ui.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) - -@Composable -fun SampoomManagementTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/ui/theme/Type.kt b/app/src/main/java/com/sampoom/android/ui/theme/Type.kt deleted file mode 100644 index 9724dcf..0000000 --- a/app/src/main/java/com/sampoom/android/ui/theme/Type.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.sampoom.android.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file diff --git a/app/src/main/res/drawable-night/ic_launcher_background.xml b/app/src/main/res/drawable-night/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable-night/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_launcher_foreground.xml b/app/src/main/res/drawable-night/ic_launcher_foreground.xml new file mode 100644 index 0000000..60f045c --- /dev/null +++ b/app/src/main/res/drawable-night/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/drawable/block.xml b/app/src/main/res/drawable/block.xml new file mode 100644 index 0000000..2ce7fd6 --- /dev/null +++ b/app/src/main/res/drawable/block.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/body.xml b/app/src/main/res/drawable/body.xml new file mode 100644 index 0000000..3ddac4e --- /dev/null +++ b/app/src/main/res/drawable/body.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/car.xml b/app/src/main/res/drawable/car.xml new file mode 100644 index 0000000..f89b46b --- /dev/null +++ b/app/src/main/res/drawable/car.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/cart.xml b/app/src/main/res/drawable/cart.xml new file mode 100644 index 0000000..12a29bd --- /dev/null +++ b/app/src/main/res/drawable/cart.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/chassis.xml b/app/src/main/res/drawable/chassis.xml new file mode 100644 index 0000000..b73b9dd --- /dev/null +++ b/app/src/main/res/drawable/chassis.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/chevron_right.xml b/app/src/main/res/drawable/chevron_right.xml new file mode 100644 index 0000000..6c71c89 --- /dev/null +++ b/app/src/main/res/drawable/chevron_right.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/dashboard.xml b/app/src/main/res/drawable/dashboard.xml new file mode 100644 index 0000000..f7b65e4 --- /dev/null +++ b/app/src/main/res/drawable/dashboard.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/delete.xml b/app/src/main/res/drawable/delete.xml new file mode 100644 index 0000000..b5b0071 --- /dev/null +++ b/app/src/main/res/drawable/delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/electric.xml b/app/src/main/res/drawable/electric.xml new file mode 100644 index 0000000..223c5da --- /dev/null +++ b/app/src/main/res/drawable/electric.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/employee.xml b/app/src/main/res/drawable/employee.xml new file mode 100644 index 0000000..8ac942e --- /dev/null +++ b/app/src/main/res/drawable/employee.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/engine.xml b/app/src/main/res/drawable/engine.xml new file mode 100644 index 0000000..3785215 --- /dev/null +++ b/app/src/main/res/drawable/engine.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..27ea91f --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,5 @@ + + + + + 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/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..ca3826a 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,74 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..078cfa1 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,20 @@ - - - - - - - - + android:viewportWidth="512" + android:viewportHeight="512"> + - \ No newline at end of file + android:pathData="M0,0h512v512h-512z" + android:fillColor="#ffffff"/> + + + + diff --git a/app/src/main/res/drawable/money.xml b/app/src/main/res/drawable/money.xml new file mode 100644 index 0000000..542612e --- /dev/null +++ b/app/src/main/res/drawable/money.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/oneline_logo.xml b/app/src/main/res/drawable/oneline_logo.xml new file mode 100644 index 0000000..fc8400c --- /dev/null +++ b/app/src/main/res/drawable/oneline_logo.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/orders.xml b/app/src/main/res/drawable/orders.xml new file mode 100644 index 0000000..41a7732 --- /dev/null +++ b/app/src/main/res/drawable/orders.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/outbound.xml b/app/src/main/res/drawable/outbound.xml new file mode 100644 index 0000000..9069781 --- /dev/null +++ b/app/src/main/res/drawable/outbound.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/outline_close.xml b/app/src/main/res/drawable/outline_close.xml new file mode 100644 index 0000000..dc414f5 --- /dev/null +++ b/app/src/main/res/drawable/outline_close.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_home_24.xml b/app/src/main/res/drawable/outline_home_24.xml new file mode 100644 index 0000000..ef91752 --- /dev/null +++ b/app/src/main/res/drawable/outline_home_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/parts.xml b/app/src/main/res/drawable/parts.xml new file mode 100644 index 0000000..f55ed30 --- /dev/null +++ b/app/src/main/res/drawable/parts.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/search.xml b/app/src/main/res/drawable/search.xml new file mode 100644 index 0000000..c8582cb --- /dev/null +++ b/app/src/main/res/drawable/search.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml new file mode 100644 index 0000000..20ed042 --- /dev/null +++ b/app/src/main/res/drawable/settings.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/square_logo.xml b/app/src/main/res/drawable/square_logo.xml new file mode 100644 index 0000000..ebe4e94 --- /dev/null +++ b/app/src/main/res/drawable/square_logo.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/transmission.xml b/app/src/main/res/drawable/transmission.xml new file mode 100644 index 0000000..0af0ab7 --- /dev/null +++ b/app/src/main/res/drawable/transmission.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/trim.xml b/app/src/main/res/drawable/trim.xml new file mode 100644 index 0000000..ffb4a25 --- /dev/null +++ b/app/src/main/res/drawable/trim.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/warning.xml b/app/src/main/res/drawable/warning.xml new file mode 100644 index 0000000..bd362d8 --- /dev/null +++ b/app/src/main/res/drawable/warning.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/font/gmarket_sans_bold.otf b/app/src/main/res/font/gmarket_sans_bold.otf new file mode 100644 index 0000000..3a7ab60 Binary files /dev/null and b/app/src/main/res/font/gmarket_sans_bold.otf differ diff --git a/app/src/main/res/font/gmarket_sans_light.otf b/app/src/main/res/font/gmarket_sans_light.otf new file mode 100644 index 0000000..c588d3e Binary files /dev/null and b/app/src/main/res/font/gmarket_sans_light.otf differ diff --git a/app/src/main/res/font/gmarket_sans_medium.otf b/app/src/main/res/font/gmarket_sans_medium.otf new file mode 100644 index 0000000..af2cfc3 Binary files /dev/null and b/app/src/main/res/font/gmarket_sans_medium.otf differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755..ef49c99 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755..ef49c99 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78..1ed8d22 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index b2dfe3d..46762f5 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d6..371dc79 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 62b611d..4898eeb 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 948a307..5b3a93f 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1b9a695..e963f60 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77..0e089a9 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 9287f50..7581993 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d642..71adf05 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 9126ae3..c436d4d 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..b2196f0 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,28 @@ + + + #FF000000 + #FFFFFFFF + + #10B981 + #FF6C6C + #F59E0B + + #444444 + #7C7C7C + #CCCCCC + #E9EAEC + + #1F1F5C + #333399 + #4C4CBB + #6666DD + #8080FF + #9999FF + #B3B3FF + #CCCCFF + #E6E6FF + + #17181B + #36393F + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values-night/ic_launcher_background.xml b/app/src/main/res/values-night/ic_launcher_background.xml new file mode 100644 index 0000000..8314ac8 --- /dev/null +++ b/app/src/main/res/values-night/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #36393F + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..ccb60ae --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..1af7af7 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,28 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 #FF000000 #FFFFFFFF + + #10B981 + #FF6C6C + #F59E0B + + #444444 + #7C7C7C + #CCCCCC + #E9EAEC + + #1F1F5C + #333399 + #4C4CBB + #6666DD + #8080FF + #9999FF + #B3B3FF + #CCCCFF + #E6E6FF + + #F5F5F5 + #FFFFFF + #36393F \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb0735c..1001f02 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,155 @@ - SampoomManagement + 삼품관리 + + + 뒤로가기 + 대시보드 + 출고목록 + 장바구니 + 주문관리 + 직원관리 + 설정 + + + 로그인 + 이메일 + 비밀번호 + 이메일 입력 + 비밀번호 입력 + 로그인 + 로그인 중… + 계정이 없으신가요? + 회원가입 + 하기 + + + 회원가입 + 이름 + 지점 + 직급 + 이메일 + 비밀번호 + 비밀번호 확인 + 이름 입력 + 지점 선택 + 직급 선택 + 이메일 입력 + 비밀번호 입력 + 비밀번호 입력 확인 + 회원가입 + 회원 가입 중… + + + 안녕하세요, + 님! + 오늘도 효율적인 재고 관리를 시작해보세요. + 직원 관리 + 총 부품 + 품절 부품 + 부족 부품 + 보유 부품 + 최근 주문 + 이번 주 요약 + 입고 부품 + 출고 부품 + + + 부품조회 + 검색 + 카테고리 선택 + 그룹 선택 + 부품명으로 검색 + 카테고리 목록이 없습니다. + 부품 목록이 없습니다. + 카테고리를 선택해주세요. + 그룹 목록이 없습니다. + 검색 결과가 없습니다. + 부품명으로 검색어를 입력하세요. + 검색어 지우기 + + + 현재 재고 : + 수량 + - + + + 출고목록에 추가 + 장바구니에 추가 + + + 출고목록 + 비우기 + 출고목록이 없습니다. + 출고 목록에 추가하시겠습니까? + 출고 목록에 추가되었습니다 + 출고 처리하시겠습니까? + 출고목록을 비우시겠습니까? + 출고 처리되었습니다 + 출고처리 + + + 장바구니 + 비우기 + 장바구니 목록이 없습니다. + 장바구니 목록에 추가하시겠습니까? + 장바구니 목록에 추가되었습니다 + 장바구니 목록의 상품을 주문하시겠습니까? + 장바구니를 비우시겠습니까? + 주문이 완료되었습니다 + 주문하기 + + + 주문관리 + 주문관리 목록이 없습니다. + 주문정보 + 주문번호 + 주문일자 + 대리점 + 주문상태 + 주문상품 + 주문취소 + 주문 취소처리하시겠습니까? + 주문 취소처리되었습니다 + 입고처리 + 입고 처리하시겠습니까? + 입고 처리되었습니다 + 총 가격 + + + 대기중 + 주문확인 + 배송중 + 배송지연 + 생산중 + 배송완료 + 입고완료 + 주문취소 + + + 설정 + 프로필 수정 + 로그아웃 + 로그아웃 하시겠습니까? + + + 오류가 발생했습니다 + 내용을 입력해주세요 + 다시 시도 + 확인 + 취소 + 닫기 + 삭제 + 상세 보기 + EA + - + + + 이메일을 입력해주세요 + 올바른 이메일 형식이 아닙니다 + 비밀번호를 입력해주세요 + 비밀번호는 최소 8자 이상이어야 합니다 + 비밀번호는 최대 30자까지 가능합니다 + 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다 + 비밀번호 확인을 입력해주세요 + 비밀번호가 일치하지 않습니다 + %s을(를) 입력해주세요 \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 4048c21..fcba5f8 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,9 @@ - - - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..233e11b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + id("com.google.devtools.ksp") version "2.2.20-2.0.3" apply false + id("com.google.dagger.hilt.android") version "2.57.2" apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c3d1c4..a1a524e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,39 @@ [versions] agp = "8.13.0" -kotlin = "2.0.21" +coreSplashscreen = "1.0.1" +datastorePreferences = "1.1.7" +desugar_jdk_libs = "2.1.5" +hiltAndroid = "2.57.2" +hiltNavigationCompose = "1.3.0" +kotlin = "2.2.20" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" -composeBom = "2024.09.00" +composeBom = "2025.10.01" +loggingInterceptor = "5.2.1" +material = "1.9.4" +materialIconsCore = "1.7.8" +navigationCompose = "2.9.5" +retrofitVersion = "3.0.0" +paging = "3.3.6" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "hiltNavigationCompose" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCore" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofitVersion" } +desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -24,6 +47,10 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } +androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }