diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ccbfc2..0ba8b70 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,6 +54,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -78,6 +79,12 @@ dependencies { implementation(libs.retrofit) implementation(libs.converter.gson) + // OKHttp + implementation(libs.logging.interceptor) + + // DataStore + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.material) implementation(libs.androidx.material.icons.core) diff --git a/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt b/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt index 3368d4b..d167d4b 100644 --- a/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt +++ b/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt @@ -19,9 +19,11 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.sampoom.android.R import com.sampoom.android.feature.auth.ui.LoginScreen +import com.sampoom.android.feature.auth.ui.SignUpScreen import com.sampoom.android.feature.part.ui.PartScreen const val ROUTE_LOGIN = "login" +const val ROUTE_SIGNUP = "signup" const val ROUTE_HOME = "home" // Main Screen @@ -51,7 +53,7 @@ fun AppNavHost() { val navController = rememberNavController() // TODO: 임시 로그인 상태 확인 -> AuthRepository에서 확인하도록 변경 - val isLoggedIn = true + val isLoggedIn = false NavHost( navController = navController, @@ -62,7 +64,22 @@ fun AppNavHost() { 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) } composable(ROUTE_PARTS) { PartScreen() } 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..23d3ae5 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/network/ErrorHandling.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.core.network + +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import retrofit2.HttpException + +data class ApiErrorResponse( + val code: Int? = null, + val message: String? = null +) + +fun Throwable.serverMessageOrNull(): String? { + if (this is HttpException) { + val errorBody = response()?.errorBody()?.string() ?: return null + 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/NetworkModule.kt b/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt index 4884ee1..fb48025 100644 --- a/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt +++ b/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt @@ -8,17 +8,37 @@ import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import javax.inject.Singleton +import com.google.gson.GsonBuilder +import com.google.gson.FieldNamingPolicy +import com.sampoom.android.BuildConfig +import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit @Module @InstallIn(SingletonComponent::class) object NetworkModule { - @Provides @Singleton fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder().build() + @Provides @Singleton fun provideOkHttp(): OkHttpClient = 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 + }) + // TODO: 로그인 기능 연동 후 인증 인터셉터 추가 필요 + .build() @Provides @Singleton - fun provideRetrofit(client: OkHttpClient): Retrofit = - Retrofit.Builder() - .baseUrl("http://10.0.2.2:8080/api/") + fun provideRetrofit(client: OkHttpClient): Retrofit { + val gson = GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create() + return Retrofit.Builder() + .baseUrl("https://sampoom.store/api/") .client(client) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson)) .build() + } } \ 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 index a9ba4d0..ca17eca 100644 --- 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 @@ -1,8 +1,8 @@ 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.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton @@ -18,6 +18,10 @@ 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.Main500 +import com.sampoom.android.core.ui.theme.White +import com.sampoom.android.core.ui.theme.disableColor +import com.sampoom.android.core.ui.theme.textSecondaryColor /** * Sampoom common button with multiple visual variants. @@ -40,15 +44,14 @@ import androidx.compose.ui.unit.dp */ @Composable fun CommonButton( - text: String, modifier: Modifier = Modifier, enabled: Boolean = true, variant: ButtonVariant = ButtonVariant.Primary, size: ButtonSize = ButtonSize.Large, leadingIcon: (@Composable (() -> Unit))? = null, - onClick: () -> Unit + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit ) { - val cs = MaterialTheme.colorScheme val shape = MaterialTheme.shapes.large val height = when (size) { ButtonSize.Large -> 56.dp @@ -64,21 +67,14 @@ fun CommonButton( shape = shape, modifier = modifier.height(height), colors = ButtonDefaults.buttonColors( - containerColor = cs.primary, - contentColor = cs.onPrimary, - disabledContainerColor = cs.onSurface.copy(alpha = 0.12f), - disabledContentColor = cs.onSurface.copy(alpha = 0.38f), + containerColor = Main500, + contentColor = White, + disabledContainerColor = disableColor(), + disabledContentColor = textSecondaryColor() ) ) { - if (leadingIcon != null) { - leadingIcon() - } - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(start = if (leadingIcon != null) 8.dp else 0.dp) - ) + if (leadingIcon != null) leadingIcon() + content() } } @@ -90,21 +86,14 @@ fun CommonButton( shape = shape, modifier = modifier.height(height), colors = ButtonDefaults.filledTonalButtonColors( - containerColor = cs.secondaryContainer, - contentColor = cs.onSecondaryContainer, - disabledContainerColor = cs.onSurface.copy(alpha = 0.08f), - disabledContentColor = cs.onSurface.copy(alpha = 0.38f) + containerColor = Main500, + contentColor = White, + disabledContainerColor = disableColor(), + disabledContentColor = textSecondaryColor() ) ) { - if (leadingIcon != null) { - leadingIcon() - } - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(start = if (leadingIcon != null) 8.dp else 0.dp) - ) + if (leadingIcon != null) leadingIcon() + content() } } @@ -115,17 +104,14 @@ fun CommonButton( enabled = enabled, shape = shape, modifier = modifier.height(height), - border = BorderStroke(1.dp, cs.primary), + border = BorderStroke(1.dp, Main500), colors = ButtonDefaults.outlinedButtonColors( - contentColor = cs.primary, - disabledContentColor = cs.onSurface.copy(alpha = 0.38f) + contentColor = Main500, + disabledContentColor = textSecondaryColor() ) ) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) + if (leadingIcon != null) leadingIcon() + content() } } @@ -137,15 +123,12 @@ fun CommonButton( shape = shape, modifier = modifier.height(height), colors = ButtonDefaults.textButtonColors( - contentColor = cs.onSurface, - disabledContentColor = cs.onSurface.copy(alpha = 0.38f) + contentColor = textSecondaryColor(), + disabledContentColor = textSecondaryColor() ) ) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) + if (leadingIcon != null) leadingIcon() + content() } } @@ -159,15 +142,12 @@ fun CommonButton( colors = ButtonDefaults.buttonColors( containerColor = Color(0xFF000000), contentColor = Color.White, - disabledContainerColor = cs.onSurface.copy(alpha = 0.12f), - disabledContentColor = cs.onSurface.copy(alpha = 0.38f), + disabledContainerColor = disableColor(), + disabledContentColor = textSecondaryColor() ) ) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) + if (leadingIcon != null) leadingIcon() + content() } } } @@ -197,7 +177,7 @@ enum class ButtonSize { Large, Medium, Small } private fun CommonButtonPreview_All() { // Primary CommonButton( - text = "Button", + content = { Text("Button", fontWeight = FontWeight.Bold) }, variant = ButtonVariant.Primary, onClick = {} ) @@ -207,7 +187,7 @@ private fun CommonButtonPreview_All() { @Composable private fun CommonButtonPreview_Primary_WithIcon() { CommonButton( - text = "Button", + content = { Text("Button", fontWeight = FontWeight.Bold) }, variant = ButtonVariant.Primary, leadingIcon = { Icon(painterResource(android.R.drawable.ic_menu_call), contentDescription = null) }, onClick = {} @@ -218,7 +198,7 @@ private fun CommonButtonPreview_Primary_WithIcon() { @Composable private fun CommonButtonPreview_Tonal() { CommonButton( - text = "Button", + content = { Text("Button", fontWeight = FontWeight.Bold) }, variant = ButtonVariant.Secondary, onClick = {} ) @@ -228,7 +208,7 @@ private fun CommonButtonPreview_Tonal() { @Composable private fun CommonButtonPreview_Outlined() { CommonButton( - text = "Button", + content = { Text("Button", fontWeight = FontWeight.Bold) }, variant = ButtonVariant.Outlined, onClick = {} ) @@ -238,7 +218,7 @@ private fun CommonButtonPreview_Outlined() { @Composable private fun CommonButtonPreview_Ghost() { CommonButton( - text = "Button", + content = { Text("Button", fontWeight = FontWeight.Bold) }, variant = ButtonVariant.Ghost, onClick = {} ) @@ -248,7 +228,7 @@ private fun CommonButtonPreview_Ghost() { @Composable private fun CommonButtonPreview_Neutral_Disabled() { CommonButton( - text = "Button", + content = { Text("Button", fontWeight = FontWeight.Bold) }, variant = ButtonVariant.Neutral, enabled = false, onClick = {} 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..34bfee0 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/ui/component/CommonSnackBar.kt @@ -0,0 +1,114 @@ +package com.sampoom.android.core.ui.component + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.statusBars +import com.sampoom.android.core.ui.theme.backgroundCardColor +import com.sampoom.android.core.ui.theme.textColor +import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButton +import androidx.compose.material3.Icon +import androidx.compose.ui.res.painterResource +import com.sampoom.android.R +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.res.stringResource + +@Composable +fun rememberCommonSnackBarHostState(): SnackbarHostState = remember { SnackbarHostState() } + +@Composable +fun ShowErrorSnackBar( + errorMessage: String?, + snackBarHostState: SnackbarHostState, + onConsumed: () -> Unit, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, + duration: SnackbarDuration = SnackbarDuration.Short +) { + LaunchedEffect(errorMessage) { + val message = errorMessage ?: return@LaunchedEffect + val result = snackBarHostState.showSnackbar( + message = message, + actionLabel = actionLabel, + withDismissAction = true, + duration = duration + ) + if (result == SnackbarResult.ActionPerformed) { + onAction?.invoke() + } + onConsumed() + } +} + +@Composable +fun TopSnackBarHost( + hostState: SnackbarHostState, + extraTopPadding: Dp = 0.dp, + showDismissButton: Boolean = true +) { + 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 = backgroundCardColor(), + contentColor = textColor(), + 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 = 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 index 52ba4bf..51d8feb 100644 --- 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 @@ -1,10 +1,13 @@ package com.sampoom.android.core.ui.component +import android.R.attr.singleLine +import android.R.attr.text 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.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation.Companion.keyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -17,32 +20,36 @@ 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, - label: String, placeholder: String, - modifier: Modifier = Modifier, enabled: Boolean = true, isPassword: Boolean = false, - variant: TextFieldVariant = TextFieldVariant.Outlined + variant: TextFieldVariant = TextFieldVariant.Outlined, + isError: Boolean = false, + errorMessage: String? = null ) { var passwordVisible by remember { mutableStateOf(false) } - val darkTheme = isSystemInDarkTheme() - val cs = MaterialTheme.colorScheme - val textColor = if (darkTheme) Color.White else Color.Black - val containerColor = if (variant == TextFieldVariant.Filled) { - if (darkTheme) Color(0xFF1C1C1E) else Color(0xFFF3F3F3) - } else Color.Transparent + val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black + val containerColor = if (variant == TextFieldVariant.Filled) backgroundColor() else Color.Transparent - val focusedBorderColor = cs.primary - val unfocusedBorderColor = if (darkTheme) Color(0xFF666666) else Color(0xFFCCCCCC) + val focusedBorderColor = if (isError) FailRed else Main500 + val unfocusedBorderColor = when { + isError -> FailRed + isSystemInDarkTheme() -> Color(0xFF666666) + else -> Color(0xFFCCCCCC) + } val trailingIconView = if (isPassword) { @Composable { @@ -50,30 +57,43 @@ fun CommonTextField( Icon( imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, contentDescription = null, - tint = if (darkTheme) Color.White else Color.Black + 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, - label = { Text(text = label, color = textColor) }, 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 = OutlinedTextFieldDefaults.colors( focusedBorderColor = focusedBorderColor, unfocusedBorderColor = unfocusedBorderColor, + errorBorderColor = FailRed, disabledBorderColor = Color.Gray, focusedLabelColor = focusedBorderColor, unfocusedLabelColor = textColor.copy(alpha = 0.7f), @@ -81,7 +101,7 @@ fun CommonTextField( focusedTextColor = textColor, unfocusedTextColor = textColor ), - shape = MaterialTheme.shapes.medium + shape = MaterialTheme.shapes.large ) } @@ -89,19 +109,21 @@ fun CommonTextField( TextField( value = value, onValueChange = onValueChange, - label = { Text(text = label, color = textColor) }, 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, @@ -109,7 +131,7 @@ fun CommonTextField( focusedTextColor = textColor, unfocusedTextColor = textColor ), - shape = MaterialTheme.shapes.medium + shape = MaterialTheme.shapes.large ) } } @@ -123,14 +145,12 @@ fun Preview_Light_CommonTextFields() { CommonTextField( value = "Example@naver.com", onValueChange = {}, - label = "이메일 입력", placeholder = "Example@naver.com", variant = TextFieldVariant.Outlined ) CommonTextField( value = "", onValueChange = {}, - label = "비밀번호 입력", placeholder = "비밀번호 입력", isPassword = true, variant = TextFieldVariant.Outlined @@ -144,20 +164,33 @@ fun Preview_Light_CommonTextFields() { fun Preview_Dark_CommonTextFields() { MaterialTheme(colorScheme = darkColorScheme()) { Column { + // 정상 CommonTextField( value = "Example@naver.com", onValueChange = {}, - label = "이메일 입력", - placeholder = "Example@naver.com", - variant = TextFieldVariant.Filled + placeholder = "이메일", + variant = TextFieldVariant.Outlined ) + + // 에러 CommonTextField( - value = "", + value = "invalid", onValueChange = {}, - label = "비밀번호 입력", - placeholder = "비밀번호 입력", + placeholder = "이메일", + variant = TextFieldVariant.Outlined, + isError = true, + errorMessage = "올바른 이메일 형식이 아닙니다" + ) + + // 비밀번호 에러 + CommonTextField( + value = "123", + onValueChange = {}, + placeholder = "비밀번호", isPassword = true, - variant = TextFieldVariant.Filled + variant = TextFieldVariant.Outlined, + isError = true, + errorMessage = "비밀번호는 최소 8자 이상이어야 합니다" ) } } 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 index 14c8259..72cdc9c 100644 --- 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 @@ -1,11 +1,47 @@ package com.sampoom.android.core.ui.theme +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +val White = Color(0xFFFFFFFF) +val Black = Color(0xFF000000) -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +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 index 26b2837..9c55a3d 100644 --- 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 @@ -11,15 +11,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 + primary = Main500, + secondary = Main300, + tertiary = Main100 ) private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 + primary = Main500, + secondary = Main300, + tertiary = Main100 /* Other default colors to override background = Color(0xFFFFFBFE), 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 index 6a3a086..ed5af64 100644 --- 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 @@ -1,34 +1,33 @@ package com.sampoom.android.core.ui.theme import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle +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.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( - 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 - ) - */ + displayLarge = baseTypography.displayLarge.copy(fontFamily = GmarketSansFamily), + displayMedium = baseTypography.displayMedium.copy(fontFamily = GmarketSansFamily), + displaySmall = baseTypography.displaySmall.copy(fontFamily = GmarketSansFamily), + headlineLarge = baseTypography.headlineLarge.copy(fontFamily = GmarketSansFamily), + headlineMedium = baseTypography.headlineMedium.copy(fontFamily = GmarketSansFamily), + headlineSmall = baseTypography.headlineSmall.copy(fontFamily = GmarketSansFamily), + titleLarge = baseTypography.titleLarge.copy(fontFamily = GmarketSansFamily), + titleMedium = baseTypography.titleMedium.copy(fontFamily = GmarketSansFamily), + titleSmall = baseTypography.titleSmall.copy(fontFamily = GmarketSansFamily), + bodyLarge = baseTypography.bodyLarge.copy(fontFamily = GmarketSansFamily), + bodyMedium = baseTypography.bodyMedium.copy(fontFamily = GmarketSansFamily), + bodySmall = baseTypography.bodySmall.copy(fontFamily = GmarketSansFamily), + labelLarge = baseTypography.labelLarge.copy(fontFamily = GmarketSansFamily), + labelMedium = baseTypography.labelMedium.copy(fontFamily = GmarketSansFamily), + labelSmall = baseTypography.labelSmall.copy(fontFamily = GmarketSansFamily) ) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/local/database/.gitkeep b/app/src/main/java/com/sampoom/android/feature/auth/data/local/database/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/local/preferences/AuthPreferences.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/local/preferences/AuthPreferences.kt index 8345a3f..789a143 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/local/preferences/AuthPreferences.kt +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/local/preferences/AuthPreferences.kt @@ -1,21 +1,50 @@ package com.sampoom.android.feature.auth.data.local.preferences import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +// 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( - @ApplicationContext private val context: Context + @param:ApplicationContext private val context: Context ){ - private val sharedPreferences = context.getSharedPreferences("auth", Context.MODE_PRIVATE) + private val dataStore = context.authDataStore - fun saveToken(token: String) { - sharedPreferences.edit().putString("token", token) + private object Keys { + val ACCESS_TOKEN: Preferences.Key = stringPreferencesKey("access_token") + val REFRESH_TOKEN: Preferences.Key = stringPreferencesKey("refresh_token") } - fun clear() { - sharedPreferences.edit().clear().apply() + + // Suspend save to avoid blocking thread + suspend fun saveToken(accessToken: String, refreshToken: String) { + dataStore.edit { prefs -> + prefs[Keys.ACCESS_TOKEN] = accessToken + prefs[Keys.REFRESH_TOKEN] = refreshToken + } } - fun hasToken(): Boolean = !sharedPreferences.getString("token", null).isNullOrEmpty() + + // Synchronous getters backed by runBlocking for minimal surface change + fun getAccessToken(): String? = runBlocking { + dataStore.data.first()[Keys.ACCESS_TOKEN] + } + + fun getRefreshToken(): String? = runBlocking { + dataStore.data.first()[Keys.REFRESH_TOKEN] + } + + suspend fun clear() { + dataStore.edit { it.clear() } + } + + fun hasToken(): Boolean = !getAccessToken().isNullOrEmpty() && !getRefreshToken().isNullOrEmpty() } \ 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 index c6bf3b0..b79dea4 100644 --- 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 @@ -1,6 +1,6 @@ package com.sampoom.android.feature.auth.data.mapper -import com.sampoom.android.feature.auth.data.remote.dto.UserDto +import com.sampoom.android.feature.auth.data.remote.dto.LoginResponseDto import com.sampoom.android.feature.auth.domain.model.User -fun UserDto.toModel(): User = User(id, name, email, token) \ No newline at end of file +fun LoginResponseDto.toModel(): User = User(userId, userName, role, accessToken, refreshToken, expiresIn) \ 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 index ce4be9b..74ff84f 100644 --- 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 @@ -1,11 +1,17 @@ package com.sampoom.android.feature.auth.data.remote.api +import com.sampoom.android.core.network.ApiResponse import com.sampoom.android.feature.auth.data.remote.dto.LoginRequestDto -import com.sampoom.android.feature.auth.data.remote.dto.UserDto +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 retrofit2.http.Body import retrofit2.http.POST interface AuthApi { - @POST("auth/login") - suspend fun login(@Body body: LoginRequestDto): UserDto + @POST("login") + suspend fun login(@Body body: LoginRequestDto): ApiResponse + + @POST("signup") + suspend fun signUp(@Body body: SignUpRequestDto): ApiResponse } \ 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..3d44982 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginResponseDto.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.auth.data.remote.dto + +data class LoginResponseDto( + val userId: Long, + val userName: String, + val role: String, + val accessToken: String, + val refreshToken: String, + val expiresIn: Int +) 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..ff24ca2 --- /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 name: String, + val workspace: String, + val branch: String, + val position: 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/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/UserDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/UserDto.kt deleted file mode 100644 index 38f07e2..0000000 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/UserDto.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.sampoom.android.feature.auth.data.remote.dto - -data class UserDto( - val id: String, - val name: String, - val email: String, - val token: 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 index 1d72da4..673d57a 100644 --- 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 @@ -4,6 +4,7 @@ import com.sampoom.android.feature.auth.data.local.preferences.AuthPreferences 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.SignUpRequestDto import com.sampoom.android.feature.auth.domain.model.User import com.sampoom.android.feature.auth.domain.repository.AuthRepository import javax.inject.Inject @@ -12,19 +13,35 @@ class AuthRepositoryImpl @Inject constructor( private val api: AuthApi, private val preferences: AuthPreferences ) : AuthRepository { + override suspend fun signUp( + name: String, + workspace: String, + branch: String, + position: String, + email: String, + password: String + ): User { + api.signUp(SignUpRequestDto( + name = name, + workspace = workspace, + branch = branch, + position = position, + email = email, + password = password + )) + return signIn(email, password) + } + override suspend fun signIn( email: String, password: String ): User { val dto = api.login(LoginRequestDto(email, password)) - preferences.saveToken(dto.token) - return dto.toModel() + preferences.saveToken(dto.data.accessToken, dto.data.refreshToken) + return dto.data.toModel() } - override suspend fun signOut() { - preferences.clear() - } + override suspend fun signOut() { preferences.clear() } override fun isSignedIn(): Boolean = preferences.hasToken() - } \ 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 index b9e6e4b..d8479ae 100644 --- 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 @@ -1,8 +1,10 @@ package com.sampoom.android.feature.auth.domain.model data class User( - val id: String, + val id: Long, val name: String, - val email: String, - val token: String + val role: String, + val accessToken: String, + val refreshToken: String, + val expiresIn: Int ) 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 index f5fccd8..747e985 100644 --- 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 @@ -3,6 +3,15 @@ package com.sampoom.android.feature.auth.domain.repository import com.sampoom.android.feature.auth.domain.model.User interface AuthRepository { + suspend fun signUp( + name: String, + workspace: String, + branch: String, + position: String, + email: String, + password: String + ): User + suspend fun signIn(email: String, password: String): User suspend fun signOut() fun isSignedIn(): Boolean diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignInUseCase.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/LoginUseCase.kt similarity index 78% rename from app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignInUseCase.kt rename to app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/LoginUseCase.kt index 630b4e2..d31282c 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignInUseCase.kt +++ b/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/LoginUseCase.kt @@ -4,8 +4,9 @@ import com.sampoom.android.feature.auth.domain.model.User import com.sampoom.android.feature.auth.domain.repository.AuthRepository import javax.inject.Inject -class SignInUseCase @Inject constructor( +class LoginUseCase @Inject constructor( private val repository: AuthRepository ) { - suspend operator fun invoke(email: String, password: String): User = repository.signIn(email, password) + suspend operator fun invoke(email: String, password: String): User = + repository.signIn(email, password) } \ 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..9fa4ddd --- /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( + name: String, + workspace: String, + branch: String, + position: String, + email: String, + password: String + ): User = repository.signUp( + name = name, + workspace = workspace, + branch = branch, + position = position, + email = email, + password = password + ) +} \ 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 index 8efc1f6..36ed2a9 100644 --- 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 @@ -1,68 +1,170 @@ 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold 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.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.input.PasswordVisualTransformation +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 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 +import com.sampoom.android.core.ui.component.ShowErrorSnackBar +import com.sampoom.android.core.ui.component.rememberCommonSnackBarHostState +import com.sampoom.android.core.ui.component.TopSnackBarHost @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 state by viewModel.state.collectAsState() - if (state.success) onSuccess() - Scaffold { innerPadding -> - Column( - Modifier + LaunchedEffect(state.success) { + if (state.success) onSuccess() + } + + val snackBarHostState = rememberCommonSnackBarHostState() + ShowErrorSnackBar( + errorMessage = state.error, + snackBarHostState = snackBarHostState, + onConsumed = { viewModel.consumeError() } + ) + Scaffold( +// contentWindowInsets = WindowInsets.ime, +// snackbarHost = { CommonSnackBarHost(snackBarHostState) } + ) { innerPadding -> + val focusManager = LocalFocusManager.current + Box( // 터치 감지용 컨테이너 + modifier = Modifier .fillMaxSize() - .padding(innerPadding) - .padding(24.dp) + .clickable( // 빈 공간 터치 시 포커스 해제 + indication = null, // 터치 ripple 제거 + interactionSource = remember { MutableInteractionSource() } + ) { + focusManager.clearFocus() + } ) { - Text(stringResource(R.string.login_title), style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(16.dp)) - OutlinedTextField( - value = state.email, - onValueChange = { viewModel.onEvent(LoginUiEvent.EmailChanged(it)) }, - label = { Text(stringResource(R.string.login_placeholder_email)) }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = state.password, - onValueChange = { viewModel.onEvent(LoginUiEvent.PasswordChanged(it)) }, - label = { Text(stringResource(R.string.login_placeholder_password)) }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(16.dp)) - Button( - onClick = { viewModel.onEvent(LoginUiEvent.Submit) }, - enabled = !state.loading, - modifier = Modifier.fillMaxWidth() - ) { Text(if (state.loading) stringResource(R.string.login_button_login_loading) else stringResource(R.string.login_button_login)) } - - state.error?.let { + 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 = state.email, + onValueChange = { viewModel.onEvent(LoginUiEvent.EmailChanged(it)) }, + placeholder = stringResource(R.string.login_placeholder_email), + isError = state.emailError != null, + errorMessage = state.emailError + ) Spacer(Modifier.height(8.dp)) - Text(it, color = MaterialTheme.colorScheme.error) + CommonTextField( + modifier = Modifier.fillMaxWidth(), + value = state.password, + onValueChange = { viewModel.onEvent(LoginUiEvent.PasswordChanged(it)) }, + placeholder = stringResource(R.string.login_placeholder_password), + isPassword = true, + isError = state.passwordError != null, + errorMessage = state.passwordError + ) + Spacer(Modifier.height(48.dp)) + CommonButton( + onClick = { viewModel.onEvent(LoginUiEvent.Submit) }, + enabled = state.isValid && !state.loading, + modifier = Modifier.fillMaxWidth() + ) { + Text( + if (state.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), + ) + } + + // per-screen inline error removed in favor of snackbar } } + TopSnackBarHost(snackBarHostState, extraTopPadding = 16.dp) } } \ 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 index dd36855..2546cab 100644 --- 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 @@ -3,7 +3,18 @@ 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 error: String? = null, 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 index c92c743..997312c 100644 --- 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 @@ -1,9 +1,13 @@ 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.feature.auth.domain.usecase.SignInUseCase +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 com.sampoom.android.core.network.serverMessageOrNull import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,23 +17,76 @@ import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( - private val singIn: SignInUseCase + private val singIn: LoginUseCase, + private val application: Application ) : ViewModel() { private val _state = MutableStateFlow(LoginUiState()) val state: StateFlow = _state + 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 -> _state.value = _state.value.copy(email = e.email) - is LoginUiEvent.PasswordChanged -> _state.value = _state.value.copy(password = e.password) + is LoginUiEvent.EmailChanged -> { + _state.value = _state.value.copy(email = e.email) + validateEmail() + } + is LoginUiEvent.PasswordChanged -> { + _state.value = _state.value.copy(password = e.password) + validatePassword() + } LoginUiEvent.Submit -> submit() } + private fun validateEmail() { + val result = AuthValidator.validateNotEmpty(_state.value.email, emailLabel) + _state.value = _state.value.copy( + emailError = result.toErrorMessage() + ) + } + + private fun validatePassword() { + val result = AuthValidator.validateNotEmpty(_state.value.password, passwordLabel) + _state.value = _state.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 (!_state.value.isValid) return@launch + val s = _state.value _state.update { it.copy(loading = true, error = null) } runCatching { singIn(s.email, s.password) } .onSuccess { _state.update { it.copy(loading = false, success = true) } } - .onFailure { _state.update { it.copy(loading = false, error = it.error) } } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + _state.update { + it.copy(loading = false, error = backendMessage ?: (throwable.message ?: errorLabel)) + } + } Log.d("LoginViewModel", "submit: ${_state.value}") } + + fun consumeError() { + _state.update { it.copy(error = null) } + } } \ 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..172037c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpScreen.kt @@ -0,0 +1,212 @@ +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.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.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.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.ui.component.CommonButton +import com.sampoom.android.core.ui.component.CommonTextField +import com.sampoom.android.core.ui.component.ShowErrorSnackBar +import com.sampoom.android.core.ui.component.rememberCommonSnackBarHostState +import com.sampoom.android.core.ui.component.TopSnackBarHost + +@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) + + 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 snackBarHostState = rememberCommonSnackBarHostState() + ShowErrorSnackBar( + errorMessage = state.error, + snackBarHostState = snackBarHostState, + onConsumed = { viewModel.consumeError() } + ) + 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 = WindowInsets.ime, +// 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(), + value = state.name, + onValueChange = { viewModel.onEvent(SignUpUiEvent.NameChanged(it)) }, + placeholder = stringResource(R.string.signup_placeholder_name), + isError = state.nameError != null, + errorMessage = state.nameError + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_branch), + fontSize = labelTextSize + ) + CommonTextField( + modifier = Modifier.fillMaxWidth(), + value = state.branch, + onValueChange = { viewModel.onEvent(SignUpUiEvent.BranchChanged(it)) }, + placeholder = stringResource(R.string.signup_placeholder_branch), + isError = state.branchError != null, + errorMessage = state.branchError + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_position), + fontSize = labelTextSize + ) + CommonTextField( + modifier = Modifier.fillMaxWidth(), + value = state.position, + onValueChange = { viewModel.onEvent(SignUpUiEvent.PositionChanged(it)) }, + placeholder = stringResource(R.string.signup_placeholder_position), + isError = state.positionError != null, + errorMessage = state.positionError + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_email), + fontSize = labelTextSize + ) + CommonTextField( + modifier = Modifier.fillMaxWidth(), + value = state.email, + onValueChange = { viewModel.onEvent(SignUpUiEvent.EmailChanged(it)) }, + placeholder = stringResource(R.string.signup_placeholder_email), + isError = state.emailError != null, + errorMessage = state.emailError + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_password), + fontSize = labelTextSize + ) + CommonTextField( + modifier = Modifier.fillMaxWidth(), + 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 + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.signup_title_password_check), + fontSize = labelTextSize + ) + CommonTextField( + modifier = Modifier.fillMaxWidth(), + 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 + ) + Spacer(Modifier.height(48.dp)) + CommonButton( + onClick = { viewModel.onEvent(SignUpUiEvent.Submit) }, + enabled = state.isValid && !state.loading, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (state.loading) stringResource(R.string.signup_button_signup_loading) + else stringResource(R.string.signup_button_signup) + ) + } + + // per-screen inline error removed in favor of snackbar + } + } + TopSnackBarHost(snackBarHostState, extraTopPadding = 16.dp) + } +} \ 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..eb8e77b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiEvent.kt @@ -0,0 +1,11 @@ +package com.sampoom.android.feature.auth.ui + +sealed interface SignUpUiEvent { + data class NameChanged(val name: String) : SignUpUiEvent + data class BranchChanged(val branch: String) : SignUpUiEvent + data class PositionChanged(val position: String) : 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..524c1fe --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiState.kt @@ -0,0 +1,37 @@ +package com.sampoom.android.feature.auth.ui + +data class SignUpUiState( + val name: String = "", + val workspace: String = "대리점", + val branch: String = "", + val position: String = "", + val email: String = "", + val password: String = "", + val passwordCheck: String = "", + + // 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 error: String? = null, + val success: Boolean = false +) { + val isValid: Boolean + get() = name.isNotBlank() && + branch.isNotBlank() && + position.isNotBlank() && + 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..70e0f79 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpViewModel.kt @@ -0,0 +1,156 @@ +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.feature.auth.domain.AuthValidator +import com.sampoom.android.feature.auth.domain.ValidationResult +import com.sampoom.android.feature.auth.domain.usecase.SignUpUseCase +import com.sampoom.android.core.network.serverMessageOrNull +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 singUp: SignUpUseCase, + private val application: Application +) : ViewModel() { + 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 = "" + + 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.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 = AuthValidator.validateNotEmpty(_state.value.position, positionLabel) + _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, error = null) } + runCatching { + singUp( + name = s.name, + workspace = s.workspace, + branch = s.branch, + position = s.position, + email = s.email, + password = s.password + ) + } + .onSuccess { _state.update { it.copy(loading = false, success = true) } } + .onFailure { throwable -> + val backendMessage = throwable.serverMessageOrNull() + _state.update { + it.copy(loading = false, error = backendMessage ?: (throwable.message ?: errorLabel)) + } + } + Log.d("SignUpViewModel", "submit: ${_state.value}") + } + + fun consumeError() { + _state.update { it.copy(error = null) } + } +} 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/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/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/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/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/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index f8c6127..b2196f0 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/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 + + #17181B + #36393F + #FFFFFF \ 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/strings.xml b/app/src/main/res/values/strings.xml index 8a5d69e..5de1348 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,6 +2,7 @@ 삼품관리 + 뒤로가기 대시보드 출고목록 장바구니 @@ -9,10 +10,32 @@ 로그인 - 이메일 - 비밀번호 + 이메일 + 비밀번호 + 이메일 입력 + 비밀번호 입력 로그인 로그인 중… + 계정이 없으신가요? + 회원가입 + 하기 + + + 회원가입 + 이름 + 지점 + 직급 + 이메일 + 비밀번호 + 비밀번호 확인 + 이름 입력 + 지점 선택 + 직급 선택 + 이메일 입력 + 비밀번호 입력 + 비밀번호 입력 확인 + 회원가입 + 회원 가입 중… 부품 목록 @@ -21,4 +44,16 @@ 오류가 발생했습니다 다시 시도 + 닫기 + + + 이메일을 입력해주세요 + 올바른 이메일 형식이 아닙니다 + 비밀번호를 입력해주세요 + 비밀번호는 최소 8자 이상이어야 합니다 + 비밀번호는 최대 30자까지 가능합니다 + 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다 + 비밀번호 확인을 입력해주세요 + 비밀번호가 일치하지 않습니다 + %s을(를) 입력해주세요 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c44195..3d5c222 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.13.0" coreSplashscreen = "1.0.1" +datastorePreferences = "1.1.7" hiltAndroid = "2.57.2" hiltNavigationCompose = "1.3.0" kotlin = "2.2.20" @@ -11,6 +12,7 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" composeBom = "2025.10.00" +loggingInterceptor = "5.2.1" material = "1.9.3" materialIconsCore = "1.7.8" navigationCompose = "2.9.5" @@ -19,6 +21,7 @@ retrofitVersion = "3.0.0" [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" } @@ -41,6 +44,7 @@ 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" } +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" } [plugins]