diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eb1c9cf..2a39a03 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,6 +72,11 @@ dependencies { implementation(project(":core:ui")) implementation(project(":presentation")) implementation(project(":feature:onboarding")) + implementation(project(":feature:auth")) + implementation(project(":feature:menu")) + implementation(project(":feature:sales")) + implementation(project(":feature:order")) + implementation(project(":feature:staff")) implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.navigation:navigation-compose:2.7.5") diff --git a/app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt b/app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt index 1a56b91..e31cf6d 100644 --- a/app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt +++ b/app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt @@ -1,5 +1,4 @@ -// app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt - +// app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt (최종 버전) package com.example.barrion.navigation import androidx.compose.foundation.layout.Box @@ -12,78 +11,128 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import com.barrion.navigation.HomeScreen +import com.example.auth.screen.LoginScreen +import com.example.auth.screen.WelcomeScreen +import com.example.menu.screen.MenuScreen import com.example.onboarding.presentation.OnboardingScreen +import com.example.onboarding.presentation.SetupStoreInfoScreen +import com.example.onboarding.presentation.SetupBusinessTypeScreen +import com.example.onboarding.presentation.SetupKioskCategoryScreen +import com.example.order.screen.OrderScreen +import com.example.sales.screen.SalesScreen +import com.example.staff.screen.StaffScreen /** * 앱의 메인 네비게이션 호스트 - * 모든 화면 간의 이동을 관리합니다. - * - * @param navController 화면 전환을 위한 네비게이션 컨트롤러 */ @Composable fun BarrionNavHost(navController: NavHostController) { - // 앱의 모든 화면 간 네비게이션을 설정 NavHost( navController = navController, - startDestination = NavRoutes.Onboarding.route // 시작 화면을 온보딩으로 설정 + startDestination = NavRoutes.Onboarding.route ) { - // 온보딩 화면 라우트 + // 온보딩 화면 composable(route = NavRoutes.Onboarding.route) { OnboardingScreen( + onNavigateToLogin = { + navController.navigate(NavRoutes.Welcome.route) { + popUpTo(NavRoutes.Onboarding.route) { inclusive = true } + } + } + ) + } + + // Welcome 화면 + composable(route = NavRoutes.Welcome.route) { + WelcomeScreen( + onNavigateToLogin = { + navController.navigate(NavRoutes.Login.route) + } + ) + } + + // 로그인 화면 - 코드별 분기 + composable(route = NavRoutes.Login.route) { + LoginScreen( onNavigateToHome = { - // 홈 화면으로 이동하면서 온보딩 화면은 백스택에서 제거 + // 코드 9999: 기능 시연용 바로 홈 navController.navigate(NavRoutes.Home.route) { - popUpTo(NavRoutes.Onboarding.route) { inclusive = true } + popUpTo(NavRoutes.Welcome.route) { inclusive = true } + } + }, + onNavigateToSetup = { + // 코드 1234: 최초 사용자 Setup 플로우 + navController.navigate(NavRoutes.SetupStoreInfo.route) { + popUpTo(NavRoutes.Welcome.route) { inclusive = true } + } + } + ) + } + + // Setup 플로우 - 1단계: 상호명 입력 + composable(route = NavRoutes.SetupStoreInfo.route) { + SetupStoreInfoScreen( + onNavigateNext = { storeName -> + // TODO: 상호명 저장 + navController.navigate(NavRoutes.SetupBusinessType.route) + }, + onNavigateBack = { + navController.navigate(NavRoutes.Login.route) { + popUpTo(NavRoutes.SetupStoreInfo.route) { inclusive = true } } } ) } - // 홈 화면 라우트 (메뉴 관리 화면) + // Setup 플로우 - 2단계: 업종 선택 + composable(route = NavRoutes.SetupBusinessType.route) { + SetupBusinessTypeScreen( + onNavigateNext = { businessType -> + // TODO: 업종 저장 + navController.navigate(NavRoutes.SetupKioskCategory.route) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + // Setup 플로우 - 3단계: 키오스크 카테고리 + composable(route = NavRoutes.SetupKioskCategory.route) { + SetupKioskCategoryScreen( + onNavigateNext = { categories -> + // TODO: 카테고리 저장 및 Setup 완료 처리 + navController.navigate(NavRoutes.Home.route) { + popUpTo(NavRoutes.SetupStoreInfo.route) { inclusive = true } + } + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + // 홈 화면 - 바텀 네비게이션 포함 composable(route = NavRoutes.Home.route) { - // 현재 구현 예정인 화면 - 구현 후 주석 해제 - // MenuScreen( - // onNavigateToOrder = { navController.navigate(NavRoutes.Order.route) }, - // onNavigateToSales = { navController.navigate(NavRoutes.Sales.route) }, - // onNavigateToStaff = { navController.navigate(NavRoutes.Staff.route) } - // ) + HomeScreen() + } - // 임시로 개발 중 메시지 표시 - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "앱 개발 중...", - style = MaterialTheme.typography.headlineMedium - ) - } + // 바텀 네비게이션 화면들 + composable(route = NavRoutes.Menu.route) { + MenuScreen() } - // 주문 관리 화면 라우트 - composable(route = NavRoutes.Order.route) { - // 현재 구현 예정인 화면 - 구현 후 주석 해제 - // OrderScreen( - // onNavigateBack = { navController.popBackStack() } - // ) + composable(route = NavRoutes.Orders.route) { + OrderScreen() } - // 매출 화면 라우트 composable(route = NavRoutes.Sales.route) { - // 현재 구현 예정인 화면 - 구현 후 주석 해제 - // SalesScreen( - // onNavigateBack = { navController.popBackStack() } - // ) + SalesScreen() } - // 직원 관리 화면 라우트 composable(route = NavRoutes.Staff.route) { - // 현재 구현 예정인 화면 - 구현 후 주석 해제 - // StaffScreen( - // onNavigateBack = { navController.popBackStack() } - // ) + StaffScreen() } - - // 추가 화면들.. } } \ No newline at end of file diff --git a/app/src/main/java/com/example/barrion/navigation/HomeScreen.kt b/app/src/main/java/com/example/barrion/navigation/HomeScreen.kt new file mode 100644 index 0000000..eef48cc --- /dev/null +++ b/app/src/main/java/com/example/barrion/navigation/HomeScreen.kt @@ -0,0 +1,71 @@ +// app/src/main/java/com/barrion/navigation/HomeScreen.kt +package com.barrion.navigation + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.example.menu.screen.MenuScreen +import com.example.order.screen.OrderScreen +import com.example.sales.screen.SalesScreen +import com.example.staff.screen.StaffScreen +import com.example.ui.components.navigation.BarrionBottomNavigation + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen() { + var currentRoute by remember { mutableStateOf("menu") } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = when (currentRoute) { + "sales" -> "매출 관리" + "menu" -> "메뉴 관리" + "orders" -> "주문 관리" + "staff" -> "직원 관리" + else -> "Barrion" + } + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + }, + bottomBar = { + BarrionBottomNavigation( + currentRoute = currentRoute, + onNavigate = { route -> + currentRoute = route + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when (currentRoute) { + "sales" -> SalesScreen() + "menu" -> MenuScreen() + "orders" -> OrderScreen() + "staff" -> StaffScreen() + else -> MenuScreen() // 기본값 + } + } + } +} + +@Preview +@Composable +private fun HomeScreenPreview() { + MaterialTheme { + HomeScreen() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/barrion/navigation/NavRoutes.kt b/app/src/main/java/com/example/barrion/navigation/NavRoutes.kt index 676d908..3cc898b 100644 --- a/app/src/main/java/com/example/barrion/navigation/NavRoutes.kt +++ b/app/src/main/java/com/example/barrion/navigation/NavRoutes.kt @@ -1,55 +1,35 @@ -// app/src/main/java/com/example/barrion/navigation/NavRoutes.kt - +// app/src/main/java/com/example/barrion/navigation/NavRoutes.kt (수정된 부분) package com.example.barrion.navigation -/** - * 앱의 네비게이션 경로를 정의하는 sealed class - * 모든 화면 경로를 한 곳에서 관리하여 일관성을 유지하고 오타를 방지합니다. - */ sealed class NavRoutes(val route: String) { /** * 온보딩 화면 경로 - 앱 최초 실행 시 표시되는 화면 - * 사용자 가이드 및 초기 설정을 포함합니다. */ object Onboarding : NavRoutes("onboarding") /** - * 홈 화면 경로 - 앱의 메인 화면 - * 메뉴 관리 기능을 제공하는 화면입니다. - */ - object Home : NavRoutes("home") - - /** - * 주문 화면 경로 - 주문 관리 기능 - * 주문 목록 확인, 주문 처리 등의 기능을 제공합니다. + * Welcome 화면 경로 - 로그인 버튼이 있는 중간 화면 */ - object Order : NavRoutes("order") + object Welcome : NavRoutes("welcome") /** - * 매출 화면 경로 - 매출 관리 및 통계 - * 매출 현황, 통계, 리포트 등을 제공합니다. + * 로그인 화면 경로 - 코드 입력 화면 */ - object Sales : NavRoutes("sales") + object Login : NavRoutes("login") /** - * 직원 관리 화면 경로 - 직원 정보 및 관리 - * 직원 목록, 근태 관리, 권한 설정 등을 제공합니다. + * 홈 화면 경로 - 바텀 네비게이션이 포함된 메인 화면 */ - object Staff : NavRoutes("staff") + object Home : NavRoutes("home") - // 파라미터가 있는 경로 예시: - // /** - // * 주문 상세 화면 경로 - 특정 주문의 상세 정보 - // * {orderId} 파라미터를 통해 특정 주문 정보를 로드합니다. - // * 사용 예시: navController.navigate("order/12345") - // */ - // object OrderDetail : NavRoutes("order/{orderId}") + // Setup 플로우 + object SetupStoreInfo : NavRoutes("setup_store_info") + object SetupBusinessType : NavRoutes("setup_business_type") + object SetupKioskCategory : NavRoutes("setup_kiosk_category") - // /** - // * URL에서 경로 파라미터를 추출하는 헬퍼 함수 - // * 사용 예시: val orderId = OrderDetail.getOrderId(savedStateHandle) - // */ - // fun getOrderId(savedStateHandle: SavedStateHandle): String { - // return checkNotNull(savedStateHandle["orderId"]) - // } + // 바텀 네비게이션 화면들 + object Menu : NavRoutes("menu") + object Orders : NavRoutes("orders") + object Sales : NavRoutes("sales") + object Staff : NavRoutes("staff") } \ No newline at end of file diff --git a/core/ui/src/main/java/com/example/ui/components/navigation/BottomNavigationBar.kt b/core/ui/src/main/java/com/example/ui/components/navigation/BottomNavigationBar.kt new file mode 100644 index 0000000..7bada9c --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/components/navigation/BottomNavigationBar.kt @@ -0,0 +1,79 @@ +// core/ui/src/main/java/com/barrion/core/ui/components/navigation/BottomNavigationBar.kt +package com.example.ui.components.navigation + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +// 네비게이션 아이템 데이터 클래스 +data class BottomNavItem( + val route: String, + val icon: ImageVector, + val label: String +) + +// 바텀 네비게이션 아이템들 정의 +object BottomNavItems { + val items = listOf( + BottomNavItem("sales", Icons.Default.Star, "매출"), + BottomNavItem("menu", Icons.Default.Home, "메뉴"), + BottomNavItem("orders", Icons.Default.Notifications, "주문"), + BottomNavItem("staff", Icons.Default.Person, "직원") + ) +} + +@Composable +fun BarrionBottomNavigation( + currentRoute: String, + onNavigate: (String) -> Unit, + modifier: Modifier = Modifier +) { + NavigationBar( + modifier = modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp + ) { + BottomNavItems.items.forEach { item -> + NavigationBarItem( + icon = { + Icon( + imageVector = item.icon, + contentDescription = item.label + ) + }, + label = { + Text( + text = item.label, + style = MaterialTheme.typography.labelSmall + ) + }, + selected = currentRoute == item.route, + onClick = { onNavigate(item.route) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + indicatorColor = MaterialTheme.colorScheme.primaryContainer + ) + ) + } + } +} + +@Preview +@Composable +private fun BarrionBottomNavigationPreview() { + MaterialTheme { + BarrionBottomNavigation( + currentRoute = "menu", + onNavigate = {} + ) + } +} \ No newline at end of file diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 8c5d6fe..ac3926d 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -1,14 +1,83 @@ plugins { - id("barrion.android.feature") - // 필요에 따라 추가 플러그인 적용 - id("barrion.hilt") - id("barrion.imageloading") + id("com.android.library") + id("org.jetbrains.kotlin.android") + + // Compose 컴파일러 플러그인 + id("org.jetbrains.kotlin.plugin.compose") + + // 필요한 Hilt와 이미지 로딩 플러그인 + id("barrion.hilt") + id("barrion.imageloading") } android { - namespace = "com.example.feature.auth" // 각 모듈에 맞는 네임스페이스 사용 + namespace = "com.example.feature.auth" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + // 이 부분 추가 ⬇️ + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + // Lint 설정 추가 ⬇️ + lint { + disable.add("NullSafeMutableLiveData") + abortOnError = false + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + // 여기까지 추가 ⬆️ + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } } -dependencies { - // 모듈 특화 의존성만 추가 -} \ No newline at end of file + dependencies { + // 코어 UI 모듈 의존성 + implementation(project(":core:ui")) + + // Hilt 관련 추가 (커스텀 플러그인이 제공 안할 경우) + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + + + // Compose 기본 의존성 + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + // Material 아이콘 확장 의존성 + implementation("androidx.compose.material:material-icons-extended:1.5.4") + + // 다른 필요한 의존성 + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + // 테스트 의존성 + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + } diff --git a/feature/auth/src/main/java/com/example/auth/AuthRepository.kt b/feature/auth/src/main/java/com/example/auth/AuthRepository.kt new file mode 100644 index 0000000..5b1fe46 --- /dev/null +++ b/feature/auth/src/main/java/com/example/auth/AuthRepository.kt @@ -0,0 +1,64 @@ +// feature/auth/AuthRepository.kt +package com.example.auth + +import com.example.auth.type.AuthResult +import com.example.auth.type.LoginRequest +import com.example.auth.type.LoginResponse +import kotlinx.coroutines.delay +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 인증 관련 데이터 처리 리포지토리 + */ +@Singleton +class AuthRepository @Inject constructor( + // private val authApi: AuthApi, + // private val tokenStorage: TokenStorage +) { + + /** + * 코드로 로그인 + */ + suspend fun login(code: String): AuthResult { + return try { + // 임시로 딜레이와 간단한 검증 로직 + delay(1000) + + // 실제 구현에서는 API 호출 + // val response = authApi.login(LoginRequest(code)) + + // 임시 검증 (실제로는 서버에서 검증) + if (code == "1234") { + // 토큰 저장 + // tokenStorage.saveTokens(response.accessToken, response.refreshToken) + AuthResult.Success + } else { + AuthResult.Error("잘못된 코드입니다.") + } + } catch (e: Exception) { + AuthResult.Error("네트워크 오류가 발생했습니다.") + } + } + + /** + * 로그아웃 + */ + suspend fun logout(): AuthResult { + return try { + // 토큰 삭제 + // tokenStorage.clearTokens() + AuthResult.Success + } catch (e: Exception) { + AuthResult.Error("로그아웃 중 오류가 발생했습니다.") + } + } + + /** + * 로그인 상태 확인 + */ + fun isLoggedIn(): Boolean { + // return tokenStorage.hasValidToken() + return false // 임시 + } +} diff --git a/feature/auth/src/main/java/com/example/auth/component/CodeInputField.kt b/feature/auth/src/main/java/com/example/auth/component/CodeInputField.kt new file mode 100644 index 0000000..1a57281 --- /dev/null +++ b/feature/auth/src/main/java/com/example/auth/component/CodeInputField.kt @@ -0,0 +1,155 @@ +// feature/auth/component/CodeInputField.kt (최종 버전) +package com.example.auth.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextStyle +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.compose.ui.unit.sp +import com.example.ui.theme.barrionColors +import kotlinx.coroutines.delay + +/** + * 4자리 코드 입력을 위한 커스텀 컴포넌트 (원래 디자인) + */ +@Composable +fun CodeInputField( + code: String, + onCodeChange: (String) -> Unit, + isError: Boolean = false, + modifier: Modifier = Modifier +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + // 4자리 완성 시 키보드 숨김 + LaunchedEffect(code) { + if (code.length >= 4) { + try { + keyboardController?.hide() + } catch (e: Exception) { + // 키보드 숨김 실패 시 무시 + } + } + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 숨겨진 텍스트 필드 (실제 입력을 받는 부분) + BasicTextField( + value = code, + onValueChange = { newCode -> + try { + // 안전한 필터링 + val filteredCode = newCode.filter { it.isDigit() }.take(4) + onCodeChange(filteredCode) + } catch (e: Exception) { + // 입력 처리 실패 시 무시 + } + }, + modifier = Modifier + .size(0.dp) // 완전히 숨김 + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + cursorBrush = SolidColor(Color.Transparent), + textStyle = TextStyle(color = Color.Transparent), + singleLine = true + ) + + // 코드 입력 박스들 (4개) + Row( + modifier = Modifier.clickable { + try { + focusRequester.requestFocus() + } catch (e: Exception) { + // 포커스 요청 실패 시 무시 + } + }, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + repeat(4) { index -> + CodeDigitBox( + digit = code.getOrNull(index)?.toString() ?: "", + isActive = code.length == index, + isError = isError, + modifier = Modifier.size(64.dp) + ) + } + } + } + + // 안전한 자동 포커스 + LaunchedEffect(Unit) { + try { + delay(300) // 충분한 지연 + focusRequester.requestFocus() + } catch (e: Exception) { + // 포커스 요청 실패 시 무시 + } + } +} + +/** + * 개별 코드 입력 박스 (원래 디자인) + */ +@Composable +private fun CodeDigitBox( + digit: String, + isActive: Boolean, + isError: Boolean, + modifier: Modifier = Modifier +) { + val borderColor = when { + isError -> MaterialTheme.barrionColors.error + isActive -> MaterialTheme.barrionColors.primaryBlue + digit.isNotEmpty() -> MaterialTheme.barrionColors.primaryBlue + else -> MaterialTheme.barrionColors.grayMediumLight + } + + val borderWidth = if (isActive || digit.isNotEmpty()) 2.dp else 1.dp + + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .border( + width = borderWidth, + color = borderColor, + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = digit, + style = MaterialTheme.typography.headlineMedium.copy( + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ), + color = if (isError) { + MaterialTheme.barrionColors.error + } else { + MaterialTheme.barrionColors.grayBlack + } + ) + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/example/auth/screen/LoginScreen.kt b/feature/auth/src/main/java/com/example/auth/screen/LoginScreen.kt new file mode 100644 index 0000000..f961bb3 --- /dev/null +++ b/feature/auth/src/main/java/com/example/auth/screen/LoginScreen.kt @@ -0,0 +1,224 @@ +// feature/auth/screen/LoginScreen.kt (업데이트된 버전) +package com.example.auth.screen + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextStyle +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.compose.ui.unit.sp +import com.example.ui.components.buttons.BarrionPrimaryButton +import com.example.ui.theme.barrionColors +import kotlinx.coroutines.delay + +/** + * 로그인 화면 - 코드별 분기 처리 + */ +@Composable +fun LoginScreen( + onNavigateToHome: () -> Unit, // 기능 시연용 (바로 홈) + onNavigateToSetup: () -> Unit, // 최초 사용자 Setup 플로우 + modifier: Modifier = Modifier +) { + var code by remember { mutableStateOf("") } + var isError by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + + Spacer(modifier = Modifier.weight(1f)) + + // 제목 텍스트 + Text( + text = "제공받은 코드를 입력해주세요 !", + style = MaterialTheme.typography.headlineSmall.copy( + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.barrionColors.grayBlack, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 설명 텍스트 + Text( + text = "코드를 받지 못하셨으면\nBarion에 문의해주시기 바랍니다.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium, + textAlign = TextAlign.Center, + lineHeight = 20.sp + ) + + Spacer(modifier = Modifier.height(48.dp)) + + // 코드 입력 박스들 + MostReliableCodeBoxes( + code = code, + onCodeChange = { newCode -> + code = newCode.take(4).filter { it.isDigit() } + isError = false // 새 입력 시 에러 해제 + }, + isError = isError, + onBoxClick = { + // 에러 상태일 때 박스 클릭하면 코드 초기화 + if (isError) { + code = "" + isError = false + } + } + ) + + // 에러 메시지 + if (isError) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Code Error", + color = MaterialTheme.barrionColors.error, + fontSize = 14.sp, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // 로그인 버튼 - 코드별 분기 처리 + BarrionPrimaryButton( + text = "로그인", + onClick = { + when (code) { + "1234" -> { + // 첫 번째 시연: 최초 사용자 Setup 플로우 + onNavigateToSetup() + } + "9999" -> { + // 두 번째 시연: 기존 사용자 바로 홈 + onNavigateToHome() + } + else -> { + // 잘못된 코드 + isError = true + } + } + }, + enabled = code.length == 4, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +/** + * 가장 확실한 방법 - 항상 키보드가 나오는 버전 + */ +@Composable +fun MostReliableCodeBoxes( + code: String, + onCodeChange: (String) -> Unit, + isError: Boolean = false, + onBoxClick: () -> Unit = {} +) { + val focusRequester = remember { FocusRequester() } + var requestFocus by remember { mutableStateOf(true) } + + // 포커스 강제 요청 + LaunchedEffect(requestFocus) { + if (requestFocus) { + delay(100) + focusRequester.requestFocus() + requestFocus = false + } + } + + Box { + // 투명한 TextField - 항상 활성화 + OutlinedTextField( + value = code, + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() }.take(4) + onCodeChange(filtered) + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .alpha(0.01f), // 거의 투명 + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ), + singleLine = true + ) + + // 박스들을 위에 오버레이 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.clickable { + onBoxClick() + requestFocus = true // 다시 포커스 요청 + } + ) { + repeat(4) { index -> + val isActive = code.length == index + val hasValue = index < code.length + + Box( + modifier = Modifier + .size(64.dp) + .border( + width = if (isActive || hasValue || isError) 2.dp else 1.dp, + color = when { + isError -> MaterialTheme.barrionColors.error + hasValue -> MaterialTheme.barrionColors.primaryBlue + isActive -> MaterialTheme.barrionColors.primaryBlue + else -> MaterialTheme.barrionColors.grayMediumLight + }, + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = code.getOrNull(index)?.toString() ?: "", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = if (isError) { + MaterialTheme.barrionColors.error + } else { + MaterialTheme.barrionColors.grayBlack + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/example/auth/screen/LoginViewModel.kt b/feature/auth/src/main/java/com/example/auth/screen/LoginViewModel.kt new file mode 100644 index 0000000..e0d5084 --- /dev/null +++ b/feature/auth/src/main/java/com/example/auth/screen/LoginViewModel.kt @@ -0,0 +1,70 @@ +// feature/auth/screen/LoginViewModel.kt +package com.example.auth.screen + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.auth.AuthRepository +import com.example.auth.type.AuthResult +import com.example.auth.type.LoginUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 로그인 화면 뷰모델 + */ +@HiltViewModel +class LoginViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _loginSuccess = MutableSharedFlow() + val loginSuccess: SharedFlow = _loginSuccess.asSharedFlow() + + /** + * 코드 업데이트 + */ + fun updateCode(code: String) { + _uiState.update { currentState -> + currentState.copy( + code = code, + isError = false, // 새로운 입력 시 에러 상태 초기화 + errorMessage = "" + ) + } + } + + /** + * 로그인 시도 + */ + fun login() { + val currentCode = _uiState.value.code + if (currentCode.length != 4) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, isError = false) } + + when (val result = authRepository.login(currentCode)) { + is AuthResult.Success -> { + _uiState.update { + it.copy(isLoading = false, isError = false) + } + _loginSuccess.emit(true) + } + is AuthResult.Error -> { + _uiState.update { + it.copy( + isLoading = false, + isError = true, + errorMessage = result.message + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/example/auth/screen/WelcomeScreen.kt b/feature/auth/src/main/java/com/example/auth/screen/WelcomeScreen.kt new file mode 100644 index 0000000..2da4504 --- /dev/null +++ b/feature/auth/src/main/java/com/example/auth/screen/WelcomeScreen.kt @@ -0,0 +1,56 @@ +// feature/auth/screen/WelcomeScreen.kt +package com.example.auth.screen + +import androidx.compose.foundation.layout.* +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.ui.components.buttons.BarrionPrimaryButton +import com.example.ui.theme.barrionColors + +/** + * 온보딩 완료 후, 로그인 전에 표시되는 환영 화면 + */ +@Composable +fun WelcomeScreen( + onNavigateToLogin: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Spacer(modifier = Modifier.weight(1f)) + + // barion 로고 텍스트 + Text( + text = "Barion", + style = MaterialTheme.typography.displayLarge.copy( + fontSize = 48.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.barrionColors.primaryBlue, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.weight(1f)) + + // 로그인 버튼 + BarrionPrimaryButton( + text = "로그인", + onClick = onNavigateToLogin, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(32.dp)) + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/example/auth/type/AuthTypes.kt b/feature/auth/src/main/java/com/example/auth/type/AuthTypes.kt new file mode 100644 index 0000000..c521e87 --- /dev/null +++ b/feature/auth/src/main/java/com/example/auth/type/AuthTypes.kt @@ -0,0 +1,45 @@ +// feature/auth/type/AuthTypes.kt +package com.example.auth.type + +/** + * 로그인 UI 상태 + */ +data class LoginUiState( + val code: String = "", + val isLoading: Boolean = false, + val isError: Boolean = false, + val errorMessage: String = "" +) + +/** + * 인증 결과 + */ +sealed class AuthResult { + object Success : AuthResult() + data class Error(val message: String) : AuthResult() +} + +/** + * 로그인 요청 데이터 + */ +data class LoginRequest( + val code: String +) + +/** + * 로그인 응답 데이터 + */ +data class LoginResponse( + val accessToken: String, + val refreshToken: String, + val userInfo: UserInfo +) + +/** + * 사용자 정보 + */ +data class UserInfo( + val id: String, + val name: String, + val role: String +) \ No newline at end of file diff --git a/feature/menu/build.gradle.kts b/feature/menu/build.gradle.kts index 8c5d6fe..5c0dad0 100644 --- a/feature/menu/build.gradle.kts +++ b/feature/menu/build.gradle.kts @@ -1,14 +1,83 @@ plugins { - id("barrion.android.feature") - // 필요에 따라 추가 플러그인 적용 - id("barrion.hilt") - id("barrion.imageloading") + id("com.android.library") + id("org.jetbrains.kotlin.android") + + // Compose 컴파일러 플러그인 + id("org.jetbrains.kotlin.plugin.compose") + + // 필요한 Hilt와 이미지 로딩 플러그인 + id("barrion.hilt") + id("barrion.imageloading") } android { - namespace = "com.example.feature.auth" // 각 모듈에 맞는 네임스페이스 사용 + namespace = "com.example.feature.menu" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + // 이 부분 추가 ⬇️ + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + // Lint 설정 추가 ⬇️ + lint { + disable.add("NullSafeMutableLiveData") + abortOnError = false + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + // 여기까지 추가 ⬆️ + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } } dependencies { - // 모듈 특화 의존성만 추가 -} \ No newline at end of file + // 코어 UI 모듈 의존성 + implementation(project(":core:ui")) + + // Hilt 관련 추가 (커스텀 플러그인이 제공 안할 경우) + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + + + // Compose 기본 의존성 + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + // Material 아이콘 확장 의존성 + implementation("androidx.compose.material:material-icons-extended:1.5.4") + + // 다른 필요한 의존성 + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + // 테스트 의존성 + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} diff --git a/feature/menu/src/main/java/com/example/menu/screen/MenuMviScreen.kt b/feature/menu/src/main/java/com/example/menu/screen/MenuMviScreen.kt new file mode 100644 index 0000000..1cf7d6f --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/screen/MenuMviScreen.kt @@ -0,0 +1,55 @@ +package com.example.menu.screen + +// feature/menu/src/main/java/com/barrion/feature/menu/screen/MenuMviScreen.kt + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.menu.type.MenuEffect +import com.example.menu.viewmodel.MenuViewModel + + +@Composable +fun MenuScreen() { + // ViewModel 없이 간단한 화면으로 변경 + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "🍽️", + style = MaterialTheme.typography.displayLarge + ) + Text( + text = "메뉴 관리", + style = MaterialTheme.typography.headlineMedium + ) + Text( + text = "메뉴 등록, 수정, 삭제 화면", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "개발 예정", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Preview +@Composable +private fun MenuScreenPreview() { + MaterialTheme { + MenuScreen() + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/type/MenuEffect.kt b/feature/menu/src/main/java/com/example/menu/type/MenuEffect.kt new file mode 100644 index 0000000..9ccca88 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/type/MenuEffect.kt @@ -0,0 +1,13 @@ +// feature/menu/src/main/java/com/barrion/feature/menu/type/MenuEffect.kt +package com.example.menu.type + +import android.view.MenuItem + + +/** + * MVI Pattern - Side Effect + * 일회성 이벤트들 (Navigation, Toast, Dialog 등) + */ +sealed class MenuEffect { + data class ShowToast(val message: String) : MenuEffect() +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/type/MenuIntent.kt b/feature/menu/src/main/java/com/example/menu/type/MenuIntent.kt new file mode 100644 index 0000000..7423d06 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/type/MenuIntent.kt @@ -0,0 +1,15 @@ +// feature/menu/src/main/java/com/barrion/feature/menu/type/MenuIntent.kt + +package com.example.menu.type + +import android.view.MenuItem + +/** + * MVI Pattern - Intent + * 사용자의 모든 행동과 시스템 이벤트를 나타내는 Intent들 + */ +sealed class MenuIntent { + object LoadMenuData : MenuIntent() + object RefreshMenuData : MenuIntent() + object ClearError : MenuIntent() +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/type/MenuState.kt b/feature/menu/src/main/java/com/example/menu/type/MenuState.kt new file mode 100644 index 0000000..a8bd772 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/type/MenuState.kt @@ -0,0 +1,14 @@ +// feature/menu/src/main/java/com/barrion/feature/menu/type/MenuState.kt +package com.example.menu.type + +import android.view.MenuItem + + +/** + * MVI Pattern - State + * UI의 모든 상태를 나타내는 불변 데이터 클래스 + */ +data class MenuState( + val isLoading: Boolean = false, + val error: String? = null +) \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/viewmodel/MenuViewModel.kt b/feature/menu/src/main/java/com/example/menu/viewmodel/MenuViewModel.kt new file mode 100644 index 0000000..59d5de9 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/viewmodel/MenuViewModel.kt @@ -0,0 +1,60 @@ +// feature/menu/src/main/java/com/barrion/feature/menu/viewmodel/MenuViewModel.kt +package com.example.menu.viewmodel + +import android.view.MenuItem +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.menu.type.MenuEffect +import com.example.menu.type.MenuIntent +import com.example.menu.type.MenuState +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +/** + * MVI Pattern ViewModel + * Intent를 받아서 State를 변경하고 Effect를 발생시킴 + */ + +class MenuViewModel : ViewModel() { + + private val _state = MutableStateFlow(MenuState()) + val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + val effect: SharedFlow = _effect.asSharedFlow() + + init { + handleIntent(MenuIntent.LoadMenuData) + } + + fun handleIntent(intent: MenuIntent) { + when (intent) { + is MenuIntent.LoadMenuData -> loadMenuData() + is MenuIntent.RefreshMenuData -> refreshMenuData() + is MenuIntent.ClearError -> clearError() + } + } + + private fun loadMenuData() { + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true, error = null) + + try { + // 임시 로딩 시뮬레이션 + kotlinx.coroutines.delay(1000) + _state.value = _state.value.copy(isLoading = false) + } catch (e: Exception) { + _state.value = _state.value.copy( + isLoading = false, + error = "메뉴 데이터를 불러올 수 없습니다" + ) + } + } + } + + private fun refreshMenuData() = loadMenuData() + + private fun clearError() { + _state.value = _state.value.copy(error = null) + } +} \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/example/onboarding/presentation/OnboardingScreen.kt b/feature/onboarding/src/main/java/com/example/onboarding/presentation/OnboardingScreen.kt index 44a9662..69c885f 100644 --- a/feature/onboarding/src/main/java/com/example/onboarding/presentation/OnboardingScreen.kt +++ b/feature/onboarding/src/main/java/com/example/onboarding/presentation/OnboardingScreen.kt @@ -42,12 +42,12 @@ data class OnboardingPage( * 4개의 온보딩 페이지로 구성되며, 수평 페이저를 통해 사용자가 페이지를 넘길 수 있습니다. * 마지막 페이지에 도달하면 시작하기 버튼이 활성화됩니다. * - * @param onNavigateToHome 시작하기 버튼 클릭 시 호출될 콜백 함수 (홈 화면으로 이동) + * @param onNavigateToLogin 시작하기 버튼 클릭 시 호출될 콜백 함수 (로그인 화면으로 이동) */ @OptIn(ExperimentalFoundationApi::class) @Composable fun OnboardingScreen( - onNavigateToHome: () -> Unit + onNavigateToLogin: () -> Unit // 파라미터명 변경: onNavigateToHome → onNavigateToLogin ) { // 온보딩 페이지 정의 - 4개의 화면으로 구성 val pages = listOf( @@ -134,8 +134,8 @@ fun OnboardingScreen( text = "시작하기", onClick = { if (isLastPage) { - // 마지막 페이지면 홈 화면으로 이동 - onNavigateToHome() + // 마지막 페이지면 로그인 화면으로 이동 + onNavigateToLogin() // 함수명 변경: onNavigateToHome() → onNavigateToLogin() } else { // 아니면 다음 페이지로 이동 coroutineScope.launch { diff --git a/feature/onboarding/src/main/java/com/example/onboarding/presentation/SetupScreens.kt b/feature/onboarding/src/main/java/com/example/onboarding/presentation/SetupScreens.kt new file mode 100644 index 0000000..4ca5bbd --- /dev/null +++ b/feature/onboarding/src/main/java/com/example/onboarding/presentation/SetupScreens.kt @@ -0,0 +1,430 @@ +// features/onboarding/src/main/java/com/example/onboarding/presentation/SetupScreens.kt +package com.example.onboarding.presentation + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.ui.components.buttons.BarrionNavigationButtons +import com.example.ui.theme.barrionColors + +/** + * Setup 프로그레스 바 컴포넌트 + */ +@Composable +fun SetupProgressBar( + currentStep: Int, + totalSteps: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 16.dp) + ) { + repeat(totalSteps) { index -> + Box( + modifier = Modifier + .weight(1f) + .height(4.dp) + .background( + color = if (index < currentStep) { + MaterialTheme.barrionColors.primaryBlue + } else { + MaterialTheme.barrionColors.grayLight + }, + shape = RoundedCornerShape(2.dp) + ) + ) + if (index < totalSteps - 1) { + Spacer(modifier = Modifier.width(4.dp)) + } + } + } +} + +/** + * 1단계: 상호명 입력 화면 + */ +@Composable +fun SetupStoreInfoScreen( + onNavigateNext: (String) -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier +) { + var storeName by remember { mutableStateOf("") } + + Column( + modifier = modifier.fillMaxSize() + ) { + // 프로그레스 바 + SetupProgressBar(currentStep = 1, totalSteps = 3) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.Start + ) { + Spacer(modifier = Modifier.height(48.dp)) + + // 제목 + Text( + text = "운영하시는 가게 이름이\n뭔가요 ?", + style = MaterialTheme.typography.headlineLarge.copy( + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = 36.sp + ), + color = MaterialTheme.barrionColors.grayBlack, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(64.dp)) + + // 라벨 + Text( + text = "상호 명", + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ), + color = MaterialTheme.barrionColors.grayBlack, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 입력 필드 + OutlinedTextField( + value = storeName, + onValueChange = { storeName = it }, + placeholder = { + Text( + "상호명을 입력해주세요", + color = MaterialTheme.barrionColors.grayMedium, + fontSize = 16.sp + ) + }, + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.barrionColors.primaryBlue, + unfocusedBorderColor = MaterialTheme.barrionColors.grayMediumLight, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent + ), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + // 네비게이션 버튼 + BarrionNavigationButtons( + leftText = "이전", + rightText = "다음", + onLeftClick = onNavigateBack, + onRightClick = { + if (storeName.isNotBlank()) { + onNavigateNext(storeName) + } + }, + rightEnabled = storeName.isNotBlank() + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +/** + * 2단계: 업종 선택 화면 + */ +@Composable +fun SetupBusinessTypeScreen( + onNavigateNext: (String) -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier +) { + var selectedType by remember { mutableStateOf("") } + + val businessTypes = listOf( + "일반 음식점" to "식사위주로 많이 팔아요 !", + "휴게 음식점" to "주로 카페가 많아요 !", + "주점업" to "술이 메인인 곳입니다 !" + ) + + Column( + modifier = modifier.fillMaxSize() + ) { + // 프로그레스 바 + SetupProgressBar(currentStep = 2, totalSteps = 3) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.Start + ) { + Spacer(modifier = Modifier.height(48.dp)) + + // 제목 + Text( + text = "어떤 업종으로 등록하셨나요?", + style = MaterialTheme.typography.headlineLarge.copy( + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = 36.sp + ), + color = MaterialTheme.barrionColors.grayBlack, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(64.dp)) + + // 업종 선택 리스트 - 카드 스타일로 변경 + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + businessTypes.forEach { (type, description) -> + val isSelected = selectedType == type + + Card( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .clickable { selectedType = type }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.barrionColors.primaryBlue.copy(alpha = 0.1f) + } else { + MaterialTheme.barrionColors.white + } + ), + border = BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) { + MaterialTheme.barrionColors.primaryBlue + } else { + MaterialTheme.barrionColors.grayMediumLight + } + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 4.dp else 2.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = type, + style = MaterialTheme.typography.titleLarge.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.barrionColors.grayBlack + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp + ), + color = MaterialTheme.barrionColors.grayMedium + ) + } + + if (isSelected) { + Text( + text = "✓", + color = MaterialTheme.barrionColors.primaryBlue, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // 네비게이션 버튼 + BarrionNavigationButtons( + leftText = "이전", + rightText = "다음", + onLeftClick = onNavigateBack, + onRightClick = { + if (selectedType.isNotBlank()) { + onNavigateNext(selectedType) + } + }, + rightEnabled = selectedType.isNotBlank() + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +/** + * 3단계: 키오스크 카테고리 설정 화면 + */ +@Composable +fun SetupKioskCategoryScreen( + onNavigateNext: (List) -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier +) { + // 카테고리 리스트 상태 관리 (최대 4개) + var categories by remember { mutableStateOf(listOf("", "")) } + val maxCategories = 4 + + Column( + modifier = modifier.fillMaxSize() + ) { + // 프로그레스 바 + SetupProgressBar(currentStep = 3, totalSteps = 3) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.Start + ) { + Spacer(modifier = Modifier.height(48.dp)) + + // 제목 + Text( + text = "키오스크에 등록할 카테고리\n를 정해주세요 !", + style = MaterialTheme.typography.headlineLarge.copy( + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = 36.sp + ), + color = MaterialTheme.barrionColors.grayBlack, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(64.dp)) + + // 스크롤 가능한 영역 + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + // 스크롤 가능한 카테고리 리스트 + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(categories.size) { index -> + OutlinedTextField( + value = categories[index], + onValueChange = { newValue -> + categories = categories.toMutableList().apply { + this[index] = newValue + } + }, + placeholder = { + Text( + "카테고리 ${index + 1}", + color = MaterialTheme.barrionColors.grayMedium, + fontSize = 16.sp + ) + }, + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.barrionColors.grayMediumLight, + unfocusedBorderColor = MaterialTheme.barrionColors.grayMediumLight, + focusedContainerColor = MaterialTheme.barrionColors.grayVeryLight, + unfocusedContainerColor = MaterialTheme.barrionColors.grayVeryLight + ), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp) + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // 추가하기 버튼 + Button( + onClick = { + if (categories.size < maxCategories) { + categories = categories + "" + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + enabled = categories.size < maxCategories, + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.barrionColors.grayMedium, + contentColor = MaterialTheme.barrionColors.white, + disabledContainerColor = MaterialTheme.barrionColors.grayLight, + disabledContentColor = MaterialTheme.barrionColors.white + ) + ) { + Text( + text = if (categories.size < maxCategories) { + "추가하기" + } else { + "최대 4개까지 가능합니다" + }, + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // 네비게이션 버튼 - 하단 고정 + BarrionNavigationButtons( + leftText = "이전", + rightText = "다음", + onLeftClick = onNavigateBack, + onRightClick = { + val nonEmptyCategories = categories.filter { it.isNotBlank() } + onNavigateNext(nonEmptyCategories) + }, + rightEnabled = categories.any { it.isNotBlank() } + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} \ No newline at end of file diff --git a/feature/order/build.gradle.kts b/feature/order/build.gradle.kts index f17605f..abceda7 100644 --- a/feature/order/build.gradle.kts +++ b/feature/order/build.gradle.kts @@ -1,14 +1,83 @@ plugins { - id("barrion.android.feature") - // 필요에 따라 추가 플러그인 적용 + id("com.android.library") + id("org.jetbrains.kotlin.android") + + // Compose 컴파일러 플러그인 + id("org.jetbrains.kotlin.plugin.compose") + + // 필요한 Hilt와 이미지 로딩 플러그인 id("barrion.hilt") id("barrion.imageloading") } android { - namespace = "com.example.feature.auth" // 각 모듈에 맞는 네임스페이스 사용 + namespace = "com.example.feature.order" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + // 이 부분 추가 ⬇️ + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + // Lint 설정 추가 ⬇️ + lint { + disable.add("NullSafeMutableLiveData") + abortOnError = false + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + // 여기까지 추가 ⬆️ + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } } dependencies { - // 모듈 특화 의존성만 추가 -} \ No newline at end of file + // 코어 UI 모듈 의존성 + implementation(project(":core:ui")) + + // Hilt 관련 추가 (커스텀 플러그인이 제공 안할 경우) + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + + + // Compose 기본 의존성 + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + // Material 아이콘 확장 의존성 + implementation("androidx.compose.material:material-icons-extended:1.5.4") + + // 다른 필요한 의존성 + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + // 테스트 의존성 + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} diff --git a/feature/order/src/main/java/com/example/order/screen/OrderScreen.kt b/feature/order/src/main/java/com/example/order/screen/OrderScreen.kt new file mode 100644 index 0000000..f9264ba --- /dev/null +++ b/feature/order/src/main/java/com/example/order/screen/OrderScreen.kt @@ -0,0 +1,73 @@ +package com.example.order.screen + +// feature/order/src/main/java/com/barrion/feature/order/screen/OrderScreen.kt + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.order.type.OrderEffect +import com.example.order.viewmodel.OrderViewModel + + +@Composable +fun OrderScreen( + viewModel: OrderViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + // Effect 처리 + LaunchedEffect(viewModel.effect) { + viewModel.effect.collect { effect -> + when (effect) { + is OrderEffect.ShowToast -> { + // Toast 처리 + } + is OrderEffect.NavigateToOrderDetail -> { + // Navigation 처리 + } + } + } + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "📋", + style = MaterialTheme.typography.displayLarge + ) + Text( + text = "주문 관리", + style = MaterialTheme.typography.headlineMedium + ) + Text( + text = "실시간 주문 접수 및 처리 화면", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "개발 예정", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Preview +@Composable +private fun OrderScreenPreview() { + MaterialTheme { + OrderScreen() + } +} \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/type/OrderEffect.kt b/feature/order/src/main/java/com/example/order/type/OrderEffect.kt new file mode 100644 index 0000000..e51344d --- /dev/null +++ b/feature/order/src/main/java/com/example/order/type/OrderEffect.kt @@ -0,0 +1,6 @@ +package com.example.order.type + +sealed class OrderEffect { + data class ShowToast(val message: String) : OrderEffect() + data class NavigateToOrderDetail(val orderId: String) : OrderEffect() +} \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/type/OrderIntent.kt b/feature/order/src/main/java/com/example/order/type/OrderIntent.kt new file mode 100644 index 0000000..4a2942f --- /dev/null +++ b/feature/order/src/main/java/com/example/order/type/OrderIntent.kt @@ -0,0 +1,10 @@ +package com.example.order.type + + +sealed class OrderIntent { + object LoadOrders : OrderIntent() + object RefreshOrders : OrderIntent() + data class SelectOrder(val orderId: String) : OrderIntent() + data class UpdateOrderStatus(val orderId: String, val status: OrderStatus) : OrderIntent() + object ClearError : OrderIntent() +} \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/type/OrderState.kt b/feature/order/src/main/java/com/example/order/type/OrderState.kt new file mode 100644 index 0000000..a6ec1c4 --- /dev/null +++ b/feature/order/src/main/java/com/example/order/type/OrderState.kt @@ -0,0 +1,39 @@ +package com.example.order.type + +import java.time.LocalDateTime + +data class OrderState( + val orders: List = emptyList(), + val selectedOrder: Order? = null, + val isLoading: Boolean = false, + val error: String? = null +) { + val pendingOrders: List + get() = orders.filter { it.status == OrderStatus.PENDING } + + val preparingOrders: List + get() = orders.filter { it.status == OrderStatus.PREPARING } + + val readyOrders: List + get() = orders.filter { it.status == OrderStatus.READY } +} + +data class Order( + val id: String, + val tableNumber: Int, + val items: List, + val status: OrderStatus, + val totalAmount: Int, + val createdAt: LocalDateTime +) + +data class OrderItem( + val menuItemId: String, + val menuItemName: String, + val quantity: Int, + val unitPrice: Int +) + +enum class OrderStatus { + PENDING, PREPARING, READY, COMPLETED, CANCELLED +} \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/viewmodel/OrderViewModel.kt b/feature/order/src/main/java/com/example/order/viewmodel/OrderViewModel.kt new file mode 100644 index 0000000..d17b472 --- /dev/null +++ b/feature/order/src/main/java/com/example/order/viewmodel/OrderViewModel.kt @@ -0,0 +1,92 @@ +package com.example.order.viewmodel + +// feature/order/src/main/java/com/barrion/feature/order/viewmodel/OrderViewModel.kt + + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.order.type.Order +import com.example.order.type.OrderEffect +import com.example.order.type.OrderIntent +import com.example.order.type.OrderState +import com.example.order.type.OrderStatus +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class OrderViewModel : ViewModel() { + + private val _state = MutableStateFlow(OrderState()) + val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + val effect: SharedFlow = _effect.asSharedFlow() + + init { + handleIntent(OrderIntent.LoadOrders) + } + + fun handleIntent(intent: OrderIntent) { + when (intent) { + is OrderIntent.LoadOrders -> loadOrders() + is OrderIntent.RefreshOrders -> refreshOrders() + is OrderIntent.SelectOrder -> selectOrder(intent.orderId) + is OrderIntent.UpdateOrderStatus -> updateOrderStatus(intent.orderId, intent.status) + is OrderIntent.ClearError -> clearError() + } + } + + private fun loadOrders() { + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true, error = null) + + try { + // 임시 데이터 + val orders = getSampleOrders() + _state.value = _state.value.copy( + isLoading = false, + orders = orders + ) + } catch (e: Exception) { + _state.value = _state.value.copy( + isLoading = false, + error = "주문 데이터를 불러올 수 없습니다" + ) + } + } + } + + private fun refreshOrders() = loadOrders() + + private fun selectOrder(orderId: String) { + val order = _state.value.orders.find { it.id == orderId } + _state.value = _state.value.copy(selectedOrder = order) + + viewModelScope.launch { + _effect.emit(OrderEffect.NavigateToOrderDetail(orderId)) + } + } + + private fun updateOrderStatus(orderId: String, status: OrderStatus) { + _state.value = _state.value.copy( + orders = _state.value.orders.map { order -> + if (order.id == orderId) { + order.copy(status = status) + } else { + order + } + } + ) + + viewModelScope.launch { + _effect.emit(OrderEffect.ShowToast("주문 상태가 업데이트되었습니다")) + } + } + + private fun clearError() { + _state.value = _state.value.copy(error = null) + } + + private fun getSampleOrders(): List { + return emptyList() // 일단 빈 리스트 + } +} \ No newline at end of file diff --git a/feature/sales/build.gradle.kts b/feature/sales/build.gradle.kts index f17605f..da26c60 100644 --- a/feature/sales/build.gradle.kts +++ b/feature/sales/build.gradle.kts @@ -1,14 +1,83 @@ plugins { - id("barrion.android.feature") - // 필요에 따라 추가 플러그인 적용 + id("com.android.library") + id("org.jetbrains.kotlin.android") + + // Compose 컴파일러 플러그인 + id("org.jetbrains.kotlin.plugin.compose") + + // 필요한 Hilt와 이미지 로딩 플러그인 id("barrion.hilt") id("barrion.imageloading") } android { - namespace = "com.example.feature.auth" // 각 모듈에 맞는 네임스페이스 사용 + namespace = "com.example.feature.sale" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + // 이 부분 추가 ⬇️ + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + // Lint 설정 추가 ⬇️ + lint { + disable.add("NullSafeMutableLiveData") + abortOnError = false + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + // 여기까지 추가 ⬆️ + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } } dependencies { - // 모듈 특화 의존성만 추가 -} \ No newline at end of file + // 코어 UI 모듈 의존성 + implementation(project(":core:ui")) + + // Hilt 관련 추가 (커스텀 플러그인이 제공 안할 경우) + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + + + // Compose 기본 의존성 + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + // Material 아이콘 확장 의존성 + implementation("androidx.compose.material:material-icons-extended:1.5.4") + + // 다른 필요한 의존성 + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + // 테스트 의존성 + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} diff --git a/feature/sales/src/main/java/com/example/sales/screen/SalesScreen.kt b/feature/sales/src/main/java/com/example/sales/screen/SalesScreen.kt new file mode 100644 index 0000000..e259136 --- /dev/null +++ b/feature/sales/src/main/java/com/example/sales/screen/SalesScreen.kt @@ -0,0 +1,56 @@ +package com.example.sales.screen + +// feature/sales/src/main/java/com/barrion/feature/sales/screen/SalesScreen.kt + + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.sales.type.SalesEffect +import com.example.sales.viewmodel.SalesViewModel + + +@Composable +fun SalesScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "📊", + style = MaterialTheme.typography.displayLarge + ) + Text( + text = "매출 관리", + style = MaterialTheme.typography.headlineMedium + ) + Text( + text = "일별, 월별 매출 통계 및 분석", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "개발 예정", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Preview +@Composable +private fun SalesScreenPreview() { + MaterialTheme { + SalesScreen() + } +} \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/type/SalesEffect.kt b/feature/sales/src/main/java/com/example/sales/type/SalesEffect.kt new file mode 100644 index 0000000..3f85cdb --- /dev/null +++ b/feature/sales/src/main/java/com/example/sales/type/SalesEffect.kt @@ -0,0 +1,6 @@ +package com.example.sales.type + +sealed class SalesEffect { + data class ShowToast(val message: String) : SalesEffect() + data class ExportSalesReport(val data: List) : SalesEffect() +} \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/type/SalesIntent.kt b/feature/sales/src/main/java/com/example/sales/type/SalesIntent.kt new file mode 100644 index 0000000..a9de999 --- /dev/null +++ b/feature/sales/src/main/java/com/example/sales/type/SalesIntent.kt @@ -0,0 +1,14 @@ +package com.example.sales.type + +import java.time.LocalDate + +sealed class SalesIntent { + object LoadSalesData : SalesIntent() + data class SelectPeriod(val period: SalesPeriod) : SalesIntent() + object RefreshData : SalesIntent() + object ClearError : SalesIntent() +} + +enum class SalesPeriod { + TODAY, WEEK, MONTH, YEAR +} \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/type/SalesState.kt b/feature/sales/src/main/java/com/example/sales/type/SalesState.kt new file mode 100644 index 0000000..b98b54d --- /dev/null +++ b/feature/sales/src/main/java/com/example/sales/type/SalesState.kt @@ -0,0 +1,24 @@ +package com.example.sales.type + +import java.time.LocalDate + +data class SalesState( + val salesData: List = emptyList(), + val selectedPeriod: SalesPeriod = SalesPeriod.TODAY, + val isLoading: Boolean = false, + val error: String? = null +) { + val totalSales: Long + get() = salesData.sumOf { it.amount } + + val totalOrders: Int + get() = salesData.sumOf { it.orderCount } + + val averageOrderValue: Long + get() = if (totalOrders > 0) totalSales / totalOrders else 0 +} + +data class SalesData( + val amount: Long, + val orderCount: Int +) \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/viewmodel/SalesViewModel.kt b/feature/sales/src/main/java/com/example/sales/viewmodel/SalesViewModel.kt new file mode 100644 index 0000000..df9029b --- /dev/null +++ b/feature/sales/src/main/java/com/example/sales/viewmodel/SalesViewModel.kt @@ -0,0 +1,74 @@ +package com.example.sales.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.sales.type.SalesData +import com.example.sales.type.SalesEffect +import com.example.sales.type.SalesIntent +import com.example.sales.type.SalesPeriod +import com.example.sales.type.SalesState +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.time.LocalDate + +class SalesViewModel : ViewModel() { + + private val _state = MutableStateFlow(SalesState()) + val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + val effect: SharedFlow = _effect.asSharedFlow() + + init { + handleIntent(SalesIntent.LoadSalesData) + } + + fun handleIntent(intent: SalesIntent) { + when (intent) { + is SalesIntent.LoadSalesData -> loadSalesData() + is SalesIntent.SelectPeriod -> selectPeriod(intent.period) + is SalesIntent.RefreshData -> refreshData() + is SalesIntent.ClearError -> clearError() + } + } + + private fun loadSalesData() { + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true, error = null) + + try { + // 임시 데이터 + val salesData = getSampleSalesData() + _state.value = _state.value.copy( + isLoading = false, + salesData = salesData + ) + } catch (e: Exception) { + _state.value = _state.value.copy( + isLoading = false, + error = "매출 데이터를 불러올 수 없습니다" + ) + } + } + } + + private fun selectPeriod(period: SalesPeriod) { + _state.value = _state.value.copy(selectedPeriod = period) + loadSalesData() + } + + private fun refreshData() = loadSalesData() + + private fun clearError() { + _state.value = _state.value.copy(error = null) + } + + private fun getSampleSalesData(): List { + return listOf( + SalesData(amount = 150000, orderCount = 25), + SalesData(amount = 200000, orderCount = 30), + SalesData(amount = 180000, orderCount = 28) + ) + } +} + diff --git a/feature/staff/build.gradle.kts b/feature/staff/build.gradle.kts index f17605f..e7f01e1 100644 --- a/feature/staff/build.gradle.kts +++ b/feature/staff/build.gradle.kts @@ -1,14 +1,83 @@ plugins { - id("barrion.android.feature") - // 필요에 따라 추가 플러그인 적용 + id("com.android.library") + id("org.jetbrains.kotlin.android") + + // Compose 컴파일러 플러그인 + id("org.jetbrains.kotlin.plugin.compose") + + // 필요한 Hilt와 이미지 로딩 플러그인 id("barrion.hilt") id("barrion.imageloading") } android { - namespace = "com.example.feature.auth" // 각 모듈에 맞는 네임스페이스 사용 + namespace = "com.example.feature.staff" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + // 이 부분 추가 ⬇️ + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + // Lint 설정 추가 ⬇️ + lint { + disable.add("NullSafeMutableLiveData") + abortOnError = false + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + // 여기까지 추가 ⬆️ + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } } dependencies { - // 모듈 특화 의존성만 추가 -} \ No newline at end of file + // 코어 UI 모듈 의존성 + implementation(project(":core:ui")) + + // Hilt 관련 추가 (커스텀 플러그인이 제공 안할 경우) + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + + + // Compose 기본 의존성 + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + // Material 아이콘 확장 의존성 + implementation("androidx.compose.material:material-icons-extended:1.5.4") + + // 다른 필요한 의존성 + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + // 테스트 의존성 + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} diff --git a/feature/staff/src/main/java/com/example/staff/screen/StaffScreen.kt b/feature/staff/src/main/java/com/example/staff/screen/StaffScreen.kt new file mode 100644 index 0000000..8d6b088 --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/screen/StaffScreen.kt @@ -0,0 +1,73 @@ +package com.example.staff.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.staff.type.StaffEffect +import com.example.staff.viewmodel.StaffViewModel + +@Composable +fun StaffScreen( + viewModel: StaffViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + // Effect 처리 + LaunchedEffect(viewModel.effect) { + viewModel.effect.collect { effect -> + when (effect) { + is StaffEffect.ShowToast -> { + // Toast 처리 + } + is StaffEffect.NavigateToStaffDetail -> { + // Navigation 처리 + } + is StaffEffect.NavigateToStaffAdd -> { + // Navigation 처리 + } + } + } + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "👤", + style = MaterialTheme.typography.displayLarge + ) + Text( + text = "직원 관리", + style = MaterialTheme.typography.headlineMedium + ) + Text( + text = "직원 등록 및 권한 관리 화면", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "개발 예정", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Preview +@Composable +private fun StaffScreenPreview() { + MaterialTheme { + StaffScreen() + } +} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/type/StaffEffect.kt b/feature/staff/src/main/java/com/example/staff/type/StaffEffect.kt new file mode 100644 index 0000000..1c1df7f --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/type/StaffEffect.kt @@ -0,0 +1,7 @@ +package com.example.staff.type + +sealed class StaffEffect { + data class ShowToast(val message: String) : StaffEffect() + data class NavigateToStaffDetail(val staffId: String) : StaffEffect() + object NavigateToStaffAdd : StaffEffect() +} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/type/StaffIntent.kt b/feature/staff/src/main/java/com/example/staff/type/StaffIntent.kt new file mode 100644 index 0000000..2ffe253 --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/type/StaffIntent.kt @@ -0,0 +1,14 @@ +package com.example.staff.type + + +sealed class StaffIntent { + object LoadStaff : StaffIntent() + data class SelectStaff(val staffId: String) : StaffIntent() + data class AddStaff(val staff: Staff) : StaffIntent() + data class UpdateStaff(val staff: Staff) : StaffIntent() + data class DeleteStaff(val staffId: String) : StaffIntent() + data class UpdateStaffRole(val staffId: String, val role: StaffRole) : StaffIntent() + object ShowAddStaffDialog : StaffIntent() + object HideAddStaffDialog : StaffIntent() + object ClearError : StaffIntent() +} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/type/StaffState.kt b/feature/staff/src/main/java/com/example/staff/type/StaffState.kt new file mode 100644 index 0000000..9a1e868 --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/type/StaffState.kt @@ -0,0 +1,31 @@ +package com.example.staff.type + +data class StaffState( + val staffList: List = emptyList(), + val selectedStaff: Staff? = null, + val showAddStaffDialog: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null +) { + val adminStaff: List + get() = staffList.filter { it.role == StaffRole.ADMIN } + + val managerStaff: List + get() = staffList.filter { it.role == StaffRole.MANAGER } + + val employeeStaff: List + get() = staffList.filter { it.role == StaffRole.EMPLOYEE } +} + +data class Staff( + val id: String, + val name: String, + val email: String, + val phone: String, + val role: StaffRole, + val isActive: Boolean = true +) + +enum class StaffRole { + ADMIN, MANAGER, EMPLOYEE +} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/viewmodel/StaffViewModel.kt b/feature/staff/src/main/java/com/example/staff/viewmodel/StaffViewModel.kt new file mode 100644 index 0000000..bc523cf --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/viewmodel/StaffViewModel.kt @@ -0,0 +1,124 @@ +package com.example.staff.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.staff.type.Staff +import com.example.staff.type.StaffEffect +import com.example.staff.type.StaffIntent +import com.example.staff.type.StaffRole +import com.example.staff.type.StaffState +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class StaffViewModel : ViewModel() { + + private val _state = MutableStateFlow(StaffState()) + val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + val effect: SharedFlow = _effect.asSharedFlow() + + init { + handleIntent(StaffIntent.LoadStaff) + } + + fun handleIntent(intent: StaffIntent) { + when (intent) { + is StaffIntent.LoadStaff -> loadStaff() + is StaffIntent.SelectStaff -> selectStaff(intent.staffId) + is StaffIntent.AddStaff -> addStaff(intent.staff) + is StaffIntent.UpdateStaff -> updateStaff(intent.staff) + is StaffIntent.DeleteStaff -> deleteStaff(intent.staffId) + is StaffIntent.UpdateStaffRole -> updateStaffRole(intent.staffId, intent.role) + is StaffIntent.ShowAddStaffDialog -> showAddStaffDialog() + is StaffIntent.HideAddStaffDialog -> hideAddStaffDialog() + is StaffIntent.ClearError -> clearError() + } + } + + private fun loadStaff() { + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true, error = null) + + try { + // 임시 데이터 + val staffList = getSampleStaff() + _state.value = _state.value.copy( + isLoading = false, + staffList = staffList + ) + } catch (e: Exception) { + _state.value = _state.value.copy( + isLoading = false, + error = "직원 데이터를 불러올 수 없습니다" + ) + } + } + } + + private fun selectStaff(staffId: String) { + val staff = _state.value.staffList.find { it.id == staffId } + _state.value = _state.value.copy(selectedStaff = staff) + + viewModelScope.launch { + _effect.emit(StaffEffect.NavigateToStaffDetail(staffId)) + } + } + + private fun addStaff(staff: Staff) { + _state.value = _state.value.copy( + staffList = _state.value.staffList + staff, + showAddStaffDialog = false + ) + + viewModelScope.launch { + _effect.emit(StaffEffect.ShowToast("직원이 추가되었습니다")) + } + } + + private fun updateStaff(staff: Staff) { + _state.value = _state.value.copy( + staffList = _state.value.staffList.map { + if (it.id == staff.id) staff else it + } + ) + } + + private fun deleteStaff(staffId: String) { + _state.value = _state.value.copy( + staffList = _state.value.staffList.filter { it.id != staffId } + ) + + viewModelScope.launch { + _effect.emit(StaffEffect.ShowToast("직원이 삭제되었습니다")) + } + } + + private fun updateStaffRole(staffId: String, role: StaffRole) { + _state.value = _state.value.copy( + staffList = _state.value.staffList.map { staff -> + if (staff.id == staffId) { + staff.copy(role = role) + } else { + staff + } + } + ) + } + + private fun showAddStaffDialog() { + _state.value = _state.value.copy(showAddStaffDialog = true) + } + + private fun hideAddStaffDialog() { + _state.value = _state.value.copy(showAddStaffDialog = false) + } + + private fun clearError() { + _state.value = _state.value.copy(error = null) + } + + private fun getSampleStaff(): List { + return emptyList() // 일단 빈 리스트 + } +} \ No newline at end of file