diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c99b1a7 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.bookon" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.example.bookon" + minSdk = 34 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + testImplementation(libs.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/bookon/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/bookon/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..b533843 --- /dev/null +++ b/app/src/androidTest/java/com/example/bookon/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.bookon + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.bookon", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4c1c8ea --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/bookon/MainActivity.kt b/app/src/main/java/com/example/bookon/MainActivity.kt new file mode 100644 index 0000000..1c64e48 --- /dev/null +++ b/app/src/main/java/com/example/bookon/MainActivity.kt @@ -0,0 +1,47 @@ +package com.example.bookon + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.example.bookon.theme.BookOnTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + BookOnTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Greeting( + name = "Android", + modifier = Modifier.padding(innerPadding) + ) + } + } + } + } +} + +@Composable +fun Greeting(name: String, modifier: Modifier = Modifier) { + Text( + text = "Hello $name!", + modifier = modifier + ) +} + +@Preview(showBackground = true) +@Composable +fun GreetingPreview() { + BookOnTheme { + Greeting("Android") + } +} diff --git a/app/src/main/java/com/example/bookon/theme/Color.kt b/app/src/main/java/com/example/bookon/theme/Color.kt new file mode 100644 index 0000000..ea6068c --- /dev/null +++ b/app/src/main/java/com/example/bookon/theme/Color.kt @@ -0,0 +1,39 @@ +package com.example.bookon.theme + +import androidx.compose.ui.graphics.Color + +val ButtonColor = Color(0xFF8DC73F) +val DisabledButtonColor = Color(0xFFEBEBEB) +val InputPlaceholderTextColor = Color(0xFFAEAEB2) +val InputTextColor = Color(0xFF000000) +val WarningTextColor = Color(0xFFB60000) +val DescriptionTextColor = Color(0xFFB60000) +val DarkGrayTextColor = Color(0xFF404040) +val ErrorBackgroundColor = Color(0xFFFFF1F1) + +object BookOnColor { + val Primary = ButtonColor + val PrimaryDark = Color(0xFF72A21D) + val PrimaryPressed = Color(0xFF7BAB1F) + val PrimaryLight = Color(0xFFEFF6E3) + val PrimaryContainer = Color(0xFFF7F9EF) + val Disabled = DisabledButtonColor + val Background = Color(0xFFFBFBFC) + val Surface = Color(0xFFFFFFFF) + val SurfaceAlt = Color(0xFFF1F1F4) + val SurfaceBorder = Color(0xFFEAEAEC) + val Divider = Color(0xFFF2F2F4) + val TextPrimary = InputTextColor + val TextSecondary = Color(0xFF8E8E93) + val TextTertiary = Color(0xFF9A9AA1) + val TextPlaceholder = InputPlaceholderTextColor + val TextDarkGray = DarkGrayTextColor + val Error = WarningTextColor + val ErrorContainer = ErrorBackgroundColor + val NavigationInactive = Color(0xFFB0B0B5) + val SwitchOff = Color(0xFFD9D9D9) + val BookCoverPlaceholder = Color(0xFF6B4329) + val BookCoverSmallPlaceholder = Color(0xFFE8E0D8) + val IconContainer = Color(0xFFE8E8EA) + val StatusAvailableContainer = PrimaryLight +} diff --git a/app/src/main/java/com/example/bookon/theme/DesignToken.kt b/app/src/main/java/com/example/bookon/theme/DesignToken.kt new file mode 100644 index 0000000..6f9a164 --- /dev/null +++ b/app/src/main/java/com/example/bookon/theme/DesignToken.kt @@ -0,0 +1,80 @@ +package com.example.bookon.theme + +import androidx.compose.ui.unit.dp + +object AppSpacing { + val ScreenHorizontal = 24.dp + val HomeHorizontal = 28.dp + val ScreenVertical = 24.dp + val Section = 24.dp + val Content = 16.dp + val FieldHorizontal = 20.dp + val Item = 12.dp + val Small = 8.dp + val Tiny = 4.dp +} + +object AppRadius { + val Button = 10.dp + val Field = 16.dp + val Search = 21.dp + val Card = 16.dp + val LargeCard = 20.dp + val Chip = 12.dp + val Small = 8.dp + val IconButton = 10.dp + val Progress = 8.dp +} + +object AppElevation { + val None = 0.dp + val Field = 6.dp + val Button = 2.dp + val Card = 10.dp + val StrongCard = 20.dp + val BookCover = 4.dp +} + +object AppIconSize { + val Small = 18.dp + val Default = 24.dp + val Medium = 30.dp + val Large = 32.dp + val XLarge = 36.dp + val Avatar = 64.dp +} + +object AppComponentSize { + val MinTouchTarget = 48.dp + val ButtonHeight = 52.dp + val FieldHeight = 52.dp + val FieldBorderWidth = 1.dp + val HomeSearchHeight = 42.dp + val HomeActionButton = 36.dp + val TopBarHeight = 56.dp + val NavigationHeight = 89.dp + val ChipHeight = 34.dp + val SmallChipHeight = 24.dp + val BookCoverWidth = 132.dp + val BookCoverHeight = 177.dp + val HomeBookCoverWidth = 94.dp + val HomeBookCoverHeight = 160.dp + val HomeBookCardWidth = 112.dp + val LibraryBookCoverWidth = 166.dp + val LibraryBookCoverHeight = 234.dp + val LibrarySortToggleWidth = 130.dp + val LibrarySortToggleHeight = 36.dp + val LibrarySortOptionHeight = 30.dp + val BookListItemHeight = 88.dp + val BookListCoverWidth = 54.dp + val BookListCoverHeight = 68.dp + val PopularBookCoverWidth = 50.dp + val PopularBookCoverHeight = 64.dp + val RankingPedestalWidth = 100.dp + val RankingFirstPedestalHeight = 96.dp + val RankingSecondPedestalHeight = 76.dp + val RankingThirdPedestalHeight = 62.dp + val RankingListRowHeight = 64.dp + val StatSummaryCardHeight = 76.dp + val InfoCardIcon = 36.dp +} diff --git a/app/src/main/java/com/example/bookon/theme/Theme.kt b/app/src/main/java/com/example/bookon/theme/Theme.kt new file mode 100644 index 0000000..00410c3 --- /dev/null +++ b/app/src/main/java/com/example/bookon/theme/Theme.kt @@ -0,0 +1,73 @@ +package com.example.bookon.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = BookOnColor.Primary, + onPrimary = BookOnColor.Surface, + primaryContainer = BookOnColor.PrimaryContainer, + onPrimaryContainer = BookOnColor.TextPrimary, + secondary = BookOnColor.PrimaryPressed, + tertiary = BookOnColor.PrimaryLight, + background = BookOnColor.Background, + onBackground = BookOnColor.TextPrimary, + surface = BookOnColor.Surface, + onSurface = BookOnColor.TextPrimary, + surfaceVariant = BookOnColor.SurfaceAlt, + onSurfaceVariant = BookOnColor.TextSecondary, + outline = BookOnColor.SurfaceBorder, + error = BookOnColor.Error, + errorContainer = BookOnColor.ErrorContainer, + onErrorContainer = BookOnColor.Error, +) + +private val LightColorScheme = lightColorScheme( + primary = BookOnColor.Primary, + onPrimary = BookOnColor.Surface, + primaryContainer = BookOnColor.PrimaryContainer, + onPrimaryContainer = BookOnColor.TextPrimary, + secondary = BookOnColor.PrimaryPressed, + tertiary = BookOnColor.PrimaryLight, + background = BookOnColor.Background, + onBackground = BookOnColor.TextPrimary, + surface = BookOnColor.Surface, + onSurface = BookOnColor.TextPrimary, + surfaceVariant = BookOnColor.SurfaceAlt, + onSurfaceVariant = BookOnColor.TextSecondary, + outline = BookOnColor.SurfaceBorder, + error = BookOnColor.Error, + errorContainer = BookOnColor.ErrorContainer, + onErrorContainer = BookOnColor.Error, +) + +@Composable +fun BookOnTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/example/bookon/theme/Type.kt b/app/src/main/java/com/example/bookon/theme/Type.kt new file mode 100644 index 0000000..48465e2 --- /dev/null +++ b/app/src/main/java/com/example/bookon/theme/Type.kt @@ -0,0 +1,119 @@ +package com.example.bookon.theme + +import androidx.compose.material3.Typography as MaterialTypography +import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp +import com.example.bookon.R + +val PretendardFontFamily = FontFamily( + Font(R.font.pretendard_thin, weight = FontWeight.Thin), + Font(R.font.pretendard_extra_light, weight = FontWeight.ExtraLight), + Font(R.font.pretendard_light, weight = FontWeight.Light), + Font(R.font.pretendard_regular, weight = FontWeight.Normal), + Font(R.font.pretendard_medium, weight = FontWeight.Medium), + Font(R.font.pretendard_semi_bold, weight = FontWeight.SemiBold), + Font(R.font.pretendard_bold, weight = FontWeight.Bold), + Font(R.font.pretendard_extra_bold, weight = FontWeight.ExtraBold), + Font(R.font.pretendard_black, weight = FontWeight.Black), +) + +private val DefaultTypography = MaterialTypography() + +// 앱 전체 Material3 텍스트 스타일이 Pretendard weight 매핑을 사용하도록 설정한다. +val Typography = Typography( + displayLarge = DefaultTypography.displayLarge.copy(fontFamily = PretendardFontFamily), + displayMedium = DefaultTypography.displayMedium.copy(fontFamily = PretendardFontFamily), + displaySmall = DefaultTypography.displaySmall.copy(fontFamily = PretendardFontFamily), + headlineLarge = DefaultTypography.headlineLarge.copy(fontFamily = PretendardFontFamily), + headlineMedium = DefaultTypography.headlineMedium.copy(fontFamily = PretendardFontFamily), + headlineSmall = DefaultTypography.headlineSmall.copy(fontFamily = PretendardFontFamily), + titleLarge = DefaultTypography.titleLarge.copy(fontFamily = PretendardFontFamily), + titleMedium = DefaultTypography.titleMedium.copy(fontFamily = PretendardFontFamily), + titleSmall = DefaultTypography.titleSmall.copy(fontFamily = PretendardFontFamily), + bodyLarge = DefaultTypography.bodyLarge.copy(fontFamily = PretendardFontFamily), + bodyMedium = DefaultTypography.bodyMedium.copy(fontFamily = PretendardFontFamily), + bodySmall = DefaultTypography.bodySmall.copy(fontFamily = PretendardFontFamily), + labelLarge = DefaultTypography.labelLarge.copy(fontFamily = PretendardFontFamily), + labelMedium = DefaultTypography.labelMedium.copy(fontFamily = PretendardFontFamily), + labelSmall = DefaultTypography.labelSmall.copy(fontFamily = PretendardFontFamily), +) + +object BookOnTypography { + val screenTitle = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 28.sp, + lineHeight = 32.sp, + ) + val sectionTitle = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + lineHeight = 22.sp, + ) + val topBarTitle = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + ) + val button = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + val fieldLabel = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + ) + val fieldText = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + ) + val fieldPlaceholder = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + ) + val bodyMedium = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + val bodySemiBold = bodyMedium.copy(fontWeight = FontWeight.SemiBold) + + val caption = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ) + val chip = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + lineHeight = 16.sp, + ) + val badge = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + ) + val bookTitle = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 22.sp, + ) + val bookMeta = TextStyle( + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 10.sp, + lineHeight = 22.sp, + ) +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnBarData.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnBarData.kt new file mode 100644 index 0000000..7de1c88 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnBarData.kt @@ -0,0 +1,35 @@ +package com.example.bookon.ui.commonComponent.bar + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import com.example.bookon.R + +@Immutable +data class BookOnNavigationItem( + @param:StringRes val labelRes: Int, + @param:DrawableRes val iconRes: Int, +) + +/** + * 앱 하단 주요 목적지 4개를 기본 순서대로 제공한다. + * 반환된 항목은 BookOnBottomNavigationBar에서 선택 상태에 맞춰 색상이 적용된다. + */ +fun defaultBookOnNavigationItems(): List = listOf( + BookOnNavigationItem( + labelRes = R.string.nav_home, + iconRes = R.drawable.navigation_home, + ), + BookOnNavigationItem( + labelRes = R.string.nav_ranking, + iconRes = R.drawable.navigation_rank, + ), + BookOnNavigationItem( + labelRes = R.string.nav_library, + iconRes = R.drawable.navigation_library, + ), + BookOnNavigationItem( + labelRes = R.string.nav_my, + iconRes = R.drawable.navigation_my, + ), +) diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnBottomNavigationBar.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnBottomNavigationBar.kt new file mode 100644 index 0000000..a2e2a93 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnBottomNavigationBar.kt @@ -0,0 +1,130 @@ +package com.example.bookon.ui.commonComponent.bar + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.bookon.theme.AppComponentSize +import com.example.bookon.theme.AppIconSize +import com.example.bookon.theme.AppRadius +import com.example.bookon.theme.AppSpacing +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnBottomNavigationUiState + +/** + * 앱 하단 주요 목적지 4개를 표시하는 공통 내비게이션 바이다. + * 이미지 리소스 아이콘은 선택 상태에 따라 앱 주요 색상 또는 비활성 색상으로 표시된다. + */ +@Composable +fun BookOnBottomNavigationBar( + uiState: BookOnBottomNavigationUiState, + onItemClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + BookOnBottomNavigationBar( + items = uiState.items, + selectedIndex = uiState.selectedIndex, + onItemClick = onItemClick, + modifier = modifier, + ) +} + +/** + * 앱 하단 주요 목적지 4개를 표시하는 공통 내비게이션 바이다. + * 이미지 리소스 아이콘은 선택 상태에 따라 앱 주요 색상 또는 비활성 색상으로 표시된다. + */ +@Composable +fun BookOnBottomNavigationBar( + items: List, + selectedIndex: Int, + onItemClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .height(AppComponentSize.NavigationHeight), + color = BookOnColor.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.ScreenHorizontal), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEachIndexed { index, item -> + val selected = index == selectedIndex + val itemColor = if (selected) BookOnColor.Primary else BookOnColor.NavigationInactive + val label = stringResource(item.labelRes) + Column( + modifier = Modifier + .clip(RoundedCornerShape(AppRadius.Small)) + .selectable( + selected = selected, + role = Role.Tab, + onClick = { onItemClick(index) }, + ) + .padding(horizontal = AppSpacing.Small, vertical = AppSpacing.Small), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.Tiny), + ) { + Box( + modifier = Modifier.size(AppIconSize.Default), + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(item.iconRes), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(itemColor), + ) + } + Text( + text = label, + style = BookOnTypography.bookMeta, + color = itemColor, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnBottomNavigationBarPreview() { + BookOnTheme { + BookOnBottomNavigationBar( + items = defaultBookOnNavigationItems(), + selectedIndex = 0, + onItemClick = {}, + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnStepProgress.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnStepProgress.kt new file mode 100644 index 0000000..ba5c13b --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnStepProgress.kt @@ -0,0 +1,88 @@ +package com.example.bookon.ui.commonComponent.bar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.bookon.theme.AppRadius +import com.example.bookon.theme.AppSpacing +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnStepProgressUiState + +/** + * 회원가입처럼 순차 단계가 있는 화면에서 현재 진행 상태를 표시한다. + * currentStep은 1부터 시작하며 totalStep보다 크면 마지막 단계로 표시한다. + */ +@Composable +fun BookOnStepProgress( + uiState: BookOnStepProgressUiState, + modifier: Modifier = Modifier, +) { + BookOnStepProgress( + currentStep = uiState.currentStep, + totalStep = uiState.totalStep, + modifier = modifier, + ) +} + +/** + * 회원가입처럼 순차 단계가 있는 화면에서 현재 진행 상태를 표시한다. + * currentStep은 1부터 시작하며 totalStep보다 크면 마지막 단계로 표시한다. + */ +@Composable +fun BookOnStepProgress( + currentStep: Int, + totalStep: Int, + modifier: Modifier = Modifier, +) { + val safeTotal = totalStep.coerceAtLeast(1) + val safeCurrent = currentStep.coerceIn(1, safeTotal) + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.Small), + ) { + repeat(safeTotal) { index -> + val selected = index < safeCurrent + Box( + modifier = Modifier + .weight(1f) + .height(4.dp) + .clip(RoundedCornerShape(AppRadius.Progress)) + .background(if (selected) BookOnColor.Primary else BookOnColor.SurfaceBorder), + ) + } + } + Spacer(modifier = Modifier.height(AppSpacing.Small)) + Text( + text = "STEP $safeCurrent / $safeTotal", + style = BookOnTypography.caption, + color = BookOnColor.TextTertiary, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnStepProgressPreview() { + BookOnTheme { + BookOnStepProgress( + currentStep = 2, + totalStep = 3, + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnTopBar.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnTopBar.kt new file mode 100644 index 0000000..32c87c9 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/bar/BookOnTopBar.kt @@ -0,0 +1,132 @@ +package com.example.bookon.ui.commonComponent.bar + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.bookon.theme.AppComponentSize +import com.example.bookon.theme.AppIconSize +import com.example.bookon.theme.AppRadius +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnTopBarUiState + +/** + * 화면 상단 제목과 뒤로가기, 우측 액션 영역을 제공한다. + * Android 시스템 status bar 영역은 앱 Scaffold나 WindowInsets 정책에 맡긴다. + */ +@Composable +fun BookOnTopBar( + uiState: BookOnTopBarUiState, + modifier: Modifier = Modifier, + onBackClick: (() -> Unit)? = null, + trailingContent: @Composable () -> Unit = {}, +) { + BookOnTopBar( + title = uiState.title, + modifier = modifier, + onBackClick = onBackClick, + backContentDescription = uiState.backContentDescription, + trailingContent = trailingContent, + ) +} + +/** + * 화면 상단 제목과 뒤로가기, 우측 액션 영역을 제공한다. + * Android 시스템 status bar 영역은 앱 Scaffold나 WindowInsets 정책에 맡긴다. + */ +@Composable +fun BookOnTopBar( + title: String, + modifier: Modifier = Modifier, + onBackClick: (() -> Unit)? = null, + backContentDescription: String = "뒤로가기", + trailingContent: @Composable () -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(AppComponentSize.TopBarHeight), + ) { + if (onBackClick != null) { + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .size(40.dp) + .clip(RoundedCornerShape(AppRadius.IconButton)) + .background(BookOnColor.Background) + .clickable( + role = Role.Button, + onClickLabel = backContentDescription, + onClick = onBackClick, + ), + contentAlignment = Alignment.Center, + ) { + BackChevron() + } + } + + Text( + modifier = Modifier.align(Alignment.Center), + text = title, + style = BookOnTypography.topBarTitle, + color = BookOnColor.TextPrimary, + ) + + Box( + modifier = Modifier.align(Alignment.CenterEnd), + contentAlignment = Alignment.Center, + ) { + trailingContent() + } + } +} + +@Composable +private fun BackChevron() { + Canvas(modifier = Modifier.size(AppIconSize.Small)) { + val strokeWidth = 2.dp.toPx() + val startX = size.width * 0.6f + val centerX = size.width * 0.35f + drawLine( + color = BookOnColor.TextPrimary, + start = Offset(startX, size.height * 0.2f), + end = Offset(centerX, size.height * 0.5f), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = BookOnColor.TextPrimary, + start = Offset(centerX, size.height * 0.5f), + end = Offset(startX, size.height * 0.8f), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnTopBarPreview() { + BookOnTheme { + BookOnTopBar( + title = "내 서재", + onBackClick = {}, + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/button/BookOnButton.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/button/BookOnButton.kt new file mode 100644 index 0000000..f84418b --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/button/BookOnButton.kt @@ -0,0 +1,130 @@ +package com.example.bookon.ui.commonComponent.button + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import com.example.bookon.R +import com.example.bookon.theme.AppComponentSize +import com.example.bookon.theme.AppElevation +import com.example.bookon.theme.AppRadius +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnPrimaryButtonUiState + +/** + * Figma의 52dp 녹색 CTA 버튼을 앱 공통 스타일로 제공한다. + * enabled와 loading 상태에 따라 클릭 가능 여부와 표시 방식을 함께 제어한다. + */ +@Composable +fun BookOnPrimaryButton( + uiState: BookOnPrimaryButtonUiState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + BookOnPrimaryButton( + text = uiState.text, + onClick = onClick, + modifier = modifier, + enabled = uiState.enabled, + loading = uiState.loading, + ) +} + +/** + * Figma의 52dp 녹색 CTA 버튼을 앱 공통 스타일로 제공한다. + * enabled와 loading 상태에 따라 클릭 가능 여부와 표시 방식을 함께 제어한다. + */ +@Composable +fun BookOnPrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + loading: Boolean = false, +) { + val isClickable = enabled && !loading + val targetBackgroundColor = if (enabled) BookOnColor.Primary else BookOnColor.Disabled + val targetContentColor = if (enabled) BookOnColor.Surface else BookOnColor.TextPlaceholder + val backgroundColor by animateColorAsState(targetValue = targetBackgroundColor) + val contentColor by animateColorAsState(targetValue = targetContentColor) + val loadingDescription = stringResource(R.string.state_loading) + + Box( + modifier = modifier + .fillMaxWidth() + .height(AppComponentSize.ButtonHeight) + .shadow( + elevation = if (enabled) AppElevation.Button else AppElevation.None, + shape = RoundedCornerShape(AppRadius.Button), + ) + .clip(RoundedCornerShape(AppRadius.Button)) + .background(backgroundColor) + .semantics { + if (!enabled) disabled() + if (loading) stateDescription = loadingDescription + } + .clickable( + enabled = isClickable, + role = Role.Button, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(AppComponentSize.ChipHeight / 2), + color = contentColor, + strokeWidth = AppRadius.Progress, + ) + } else { + Text( + text = text, + style = BookOnTypography.button, + color = contentColor, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnPrimaryButtonPreview() { + BookOnTheme { + BookOnPrimaryButton( + text = "다음", + onClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnPrimaryButtonDisabledPreview() { + BookOnTheme { + BookOnPrimaryButton( + text = "다음", + onClick = {}, + enabled = false, + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/button/BookOnLogoutButton.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/button/BookOnLogoutButton.kt new file mode 100644 index 0000000..00920a3 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/button/BookOnLogoutButton.kt @@ -0,0 +1,19 @@ +package com.example.bookon.ui.commonComponent.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.example.bookon.R +import com.example.bookon.ui.commonComponent.row.BookOnMenuRow + +/** + * 마이페이지 메뉴 목록과 분리된 로그아웃 전용 버튼이다. + */ +@Composable +fun BookOnLogoutButton(onClick: () -> Unit) { + BookOnMenuRow( + title = stringResource(R.string.action_logout), + onClick = onClick, + destructive = true, + showDivider = false, + ) +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/card/BookOnBookCard.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/card/BookOnBookCard.kt new file mode 100644 index 0000000..cfd0825 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/card/BookOnBookCard.kt @@ -0,0 +1,127 @@ +package com.example.bookon.ui.commonComponent.card + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.example.bookon.theme.AppComponentSize +import com.example.bookon.theme.AppElevation +import com.example.bookon.theme.AppRadius +import com.example.bookon.theme.AppSpacing +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnBookCardUiState + +/** + * 책 표지와 제목/저자 정보를 표시하는 공통 카드이다. + * cover는 이미지 로더나 로컬 drawable을 연결할 수 있도록 slot으로 받는다. + */ +@Composable +fun BookOnBookCard( + uiState: BookOnBookCardUiState, + modifier: Modifier = Modifier, + titleStyle: TextStyle = BookOnTypography.bookTitle, + coverWidth: Dp = AppComponentSize.BookCoverWidth, + coverHeight: Dp = AppComponentSize.BookCoverHeight, + cardWidth: Dp = coverWidth, + cover: @Composable () -> Unit = { BookCoverPlaceholder(coverHeight) }, +) { + BookOnBookCard( + title = uiState.title, + author = uiState.author, + modifier = modifier, + cover = cover, + titleStyle = titleStyle, + coverWidth = coverWidth, + coverHeight = coverHeight, + cardWidth = cardWidth, + ) +} + +/** + * 책 표지와 제목/저자 정보를 표시하는 공통 카드이다. + * cover는 이미지 로더나 로컬 drawable을 연결할 수 있도록 slot으로 받는다. + */ +@Composable +fun BookOnBookCard( + title: String, + author: String, + modifier: Modifier = Modifier, + titleStyle: TextStyle = BookOnTypography.bookTitle, + coverWidth: Dp = AppComponentSize.BookCoverWidth, + coverHeight: Dp = AppComponentSize.BookCoverHeight, + cardWidth: Dp = coverWidth, + cover: @Composable () -> Unit = { BookCoverPlaceholder(coverHeight) }, +) { + Column(modifier = modifier.width(cardWidth)) { + Box( + modifier = Modifier + .size( + width = coverWidth, + height = coverHeight, + ) + .shadow( + elevation = AppElevation.BookCover, + shape = RoundedCornerShape(AppRadius.Small), + ) + .clip(RoundedCornerShape(AppRadius.Small)), + contentAlignment = Alignment.Center, + ) { + cover() + } + Spacer(modifier = Modifier.height(AppSpacing.Item)) + Text( + text = title, + style = titleStyle, + color = BookOnColor.TextPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = author, + style = BookOnTypography.bookMeta, + color = BookOnColor.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun BookCoverPlaceholder( + height: Dp = AppComponentSize.BookCoverHeight, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(height) + .background(BookOnColor.BookCoverPlaceholder), + ) +} + +@Preview(showBackground = true) +@Composable +private fun BookOnBookCardPreview() { + BookOnTheme { + BookOnBookCard( + title = "자몽 살구 클럽", + author = "한로로", + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/card/BookOnInfoCard.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/card/BookOnInfoCard.kt new file mode 100644 index 0000000..1665d46 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/card/BookOnInfoCard.kt @@ -0,0 +1,126 @@ +package com.example.bookon.ui.commonComponent.card + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.example.bookon.theme.AppComponentSize +import com.example.bookon.theme.AppIconSize +import com.example.bookon.theme.AppRadius +import com.example.bookon.theme.AppSpacing +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnInfoCardUiState + +/** + * 안내, 연동, 공지 등 아이콘과 설명이 함께 있는 공통 카드이다. + * leadingContent와 trailingContent는 화면별 아이콘이나 토글을 연결할 때 사용한다. + */ +@Composable +fun BookOnInfoCard( + uiState: BookOnInfoCardUiState, + modifier: Modifier = Modifier, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, +) { + BookOnInfoCard( + title = uiState.title, + modifier = modifier, + description = uiState.description, + containerColor = uiState.containerColor, + borderColor = uiState.borderColor, + leadingContent = leadingContent, + trailingContent = trailingContent, + ) +} + +/** + * 안내, 연동, 공지 등 아이콘과 설명이 함께 있는 공통 카드이다. + * leadingContent와 trailingContent는 화면별 아이콘이나 토글을 연결할 때 사용한다. + */ +@Composable +fun BookOnInfoCard( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + containerColor: Color = BookOnColor.Background, + borderColor: Color = BookOnColor.SurfaceBorder, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AppRadius.Card)) + .background(containerColor) + .padding(AppSpacing.Content), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leadingContent != null) { + Box( + modifier = Modifier + .size(AppComponentSize.InfoCardIcon) + .clip(RoundedCornerShape(AppRadius.IconButton)) + .background(borderColor), + contentAlignment = Alignment.Center, + ) { + leadingContent() + } + Spacer(modifier = Modifier.width(AppSpacing.Content)) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = BookOnTypography.bodySemiBold, + color = BookOnColor.TextPrimary, + ) + if (description != null) { + Spacer(modifier = Modifier.height(AppSpacing.Tiny)) + Text( + text = description, + style = BookOnTypography.caption, + color = BookOnColor.TextSecondary, + ) + } + } + + if (trailingContent != null) { + Spacer(modifier = Modifier.width(AppSpacing.Content)) + trailingContent() + } + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnInfoCardPreview() { + BookOnTheme { + BookOnInfoCard( + title = "2026 독서마라톤", + description = "아직 연동하지 않았어요", + leadingContent = { + Box( + modifier = Modifier + .size(AppIconSize.Small) + .background(BookOnColor.Primary), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/card/BookOnStatSummaryCard.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/card/BookOnStatSummaryCard.kt new file mode 100644 index 0000000..6cc5557 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/card/BookOnStatSummaryCard.kt @@ -0,0 +1,124 @@ +package com.example.bookon.ui.commonComponent.card + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.bookon.theme.AppComponentSize +import com.example.bookon.theme.AppElevation +import com.example.bookon.theme.AppRadius +import com.example.bookon.theme.AppSpacing +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnStatSummaryCardUiState + +@Immutable +data class BookOnStatItem( + val label: String, + val value: String, +) + +/** + * 마이페이지의 대출 현황처럼 2~3개 수치를 한 줄 카드로 표시한다. + * 값은 호출 측에서 이미 화면 문자열로 만든 뒤 전달한다. + */ +@Composable +fun BookOnStatSummaryCard( + uiState: BookOnStatSummaryCardUiState, + modifier: Modifier = Modifier, +) { + BookOnStatSummaryCard( + items = uiState.items, + modifier = modifier, + ) +} + +/** + * 마이페이지의 대출 현황처럼 2~3개 수치를 한 줄 카드로 표시한다. + * 값은 호출 측에서 이미 화면 문자열로 만든 뒤 전달한다. + */ +@Composable +fun BookOnStatSummaryCard( + items: List, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(AppComponentSize.StatSummaryCardHeight) + .shadow( + elevation = AppElevation.StrongCard, + shape = RoundedCornerShape(AppRadius.Card), + ) + .clip(RoundedCornerShape(AppRadius.Card)) + .background( + brush = Brush.horizontalGradient( + colors = listOf(BookOnColor.Primary, BookOnColor.PrimaryDark), + ), + ) + .padding(vertical = AppSpacing.Content), + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEachIndexed { index, item -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = item.label, + style = BookOnTypography.caption, + color = BookOnColor.PrimaryLight, + ) + Spacer(modifier = Modifier.height(AppSpacing.Tiny)) + Text( + text = item.value, + style = BookOnTypography.sectionTitle, + color = BookOnColor.PrimaryLight, + ) + } + + if (index < items.lastIndex) { + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(BookOnColor.PrimaryLight.copy(alpha = 0.45f)), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnStatSummaryCardPreview() { + BookOnTheme { + BookOnStatSummaryCard( + items = listOf( + BookOnStatItem("대출 중", "3권"), + BookOnStatItem("반납 임박", "2권"), + BookOnStatItem("누적 대출", "23권"), + ), + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/chip/BookOnChips.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/chip/BookOnChips.kt new file mode 100644 index 0000000..db94aa6 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/chip/BookOnChips.kt @@ -0,0 +1,81 @@ +package com.example.bookon.ui.commonComponent.chip + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import com.example.bookon.theme.AppComponentSize +import com.example.bookon.theme.AppRadius +import com.example.bookon.theme.AppSpacing +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnFilterChipUiState + +/** + * 검색 필터와 내역 상태 필터에 쓰는 선택형 칩이다. + * 선택 상태는 배경색과 텍스트 색상만으로 구분되지 않도록 selected 값을 semantics에 연결한다. + */ +@Composable +fun BookOnFilterChip( + uiState: BookOnFilterChipUiState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + BookOnFilterChip( + text = uiState.text, + selected = uiState.selected, + onClick = onClick, + modifier = modifier, + ) +} + +/** + * 검색 필터와 내역 상태 필터에 쓰는 선택형 칩이다. + * 선택 상태는 배경색과 텍스트 색상만으로 구분되지 않도록 selected 값을 semantics에 연결한다. + */ +@Composable +fun BookOnFilterChip( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .height(AppComponentSize.ChipHeight) + .clip(RoundedCornerShape(AppRadius.Chip)) + .background(if (selected) BookOnColor.PrimaryPressed else BookOnColor.SurfaceAlt) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = AppSpacing.Content), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = BookOnTypography.chip, + color = if (selected) BookOnColor.Surface else BookOnColor.TextDarkGray, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnFilterChipPreview() { + BookOnTheme { + Row(horizontalArrangement = Arrangement.spacedBy(AppSpacing.Small)) { + BookOnFilterChip(text = "대출 중 2", selected = false, onClick = {}) + BookOnFilterChip(text = "반납 완료", selected = true, onClick = {}) + } + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/row/BookOnMenuRow.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/row/BookOnMenuRow.kt new file mode 100644 index 0000000..1f03855 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/row/BookOnMenuRow.kt @@ -0,0 +1,126 @@ +package com.example.bookon.ui.commonComponent.row + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.bookon.theme.AppIconSize +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnMenuRowUiState + +/** + * 마이페이지 설정 목록처럼 제목, 우측 액션, 하단 구분선을 가진 행이다. + * onClick이 null이면 읽기 전용 행으로 표시한다. + */ +@Composable +fun BookOnMenuRow( + uiState: BookOnMenuRowUiState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + trailingContent: @Composable () -> Unit = { MenuChevron() }, +) { + BookOnMenuRow( + title = uiState.title, + modifier = modifier, + onClick = onClick, + destructive = uiState.destructive, + showDivider = uiState.showDivider, + trailingContent = trailingContent, + ) +} + +/** + * 마이페이지 설정 목록처럼 제목, 우측 액션, 하단 구분선을 가진 행이다. + * onClick이 null이면 읽기 전용 행으로 표시한다. + */ +@Composable +fun BookOnMenuRow( + title: String, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + destructive: Boolean = false, + showDivider: Boolean = true, + trailingContent: @Composable () -> Unit = { MenuChevron() }, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(51.dp) + .then( + if (onClick != null) { + Modifier.clickable(role = Role.Button, onClick = onClick) + } else { + Modifier + }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = title, + style = BookOnTypography.bodySemiBold, + color = if (destructive) BookOnColor.Error else BookOnColor.TextPrimary, + ) + trailingContent() + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(BookOnColor.Divider), + ) + } + } +} + +@Composable +private fun MenuChevron() { + Canvas(modifier = Modifier.size(AppIconSize.Small)) { + val strokeWidth = 1.5.dp.toPx() + val startX = size.width * 0.35f + val endX = size.width * 0.65f + drawLine( + color = BookOnColor.TextPlaceholder, + start = Offset(startX, size.height * 0.2f), + end = Offset(endX, size.height * 0.5f), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = BookOnColor.TextPlaceholder, + start = Offset(endX, size.height * 0.5f), + end = Offset(startX, size.height * 0.8f), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnMenuRowPreview() { + BookOnTheme { + BookOnMenuRow( + title = "대출 / 반납 내역", + onClick = {}, + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/row/BookOnSwitchRow.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/row/BookOnSwitchRow.kt new file mode 100644 index 0000000..b2f8168 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/row/BookOnSwitchRow.kt @@ -0,0 +1,97 @@ +package com.example.bookon.ui.commonComponent.row + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.example.bookon.theme.AppSpacing +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnSwitchRowUiState + +/** + * 제목과 설명, Switch를 한 행으로 묶어 알림/연동 설정에 사용한다. + */ +@Composable +fun BookOnSwitchRow( + uiState: BookOnSwitchRowUiState, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + BookOnSwitchRow( + title = uiState.title, + checked = uiState.checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + description = uiState.description, + ) +} + +/** + * 제목과 설명, Switch를 한 행으로 묶어 알림/연동 설정에 사용한다. + */ +@Composable +fun BookOnSwitchRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = AppSpacing.Item, horizontal = AppSpacing.Item), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = BookOnTypography.bodySemiBold, + color = BookOnColor.TextPrimary, + ) + if (description != null) { + Spacer(modifier = Modifier.height(AppSpacing.Tiny)) + Text( + text = description, + style = BookOnTypography.caption, + color = BookOnColor.TextSecondary, + ) + } + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = BookOnColor.Surface, + checkedTrackColor = BookOnColor.Primary, + uncheckedThumbColor = BookOnColor.Surface, + uncheckedTrackColor = BookOnColor.SwitchOff, + uncheckedBorderColor = BookOnColor.SwitchOff, + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnSwitchRowPreview() { + BookOnTheme { + BookOnSwitchRow( + title = "반납 알림", + description = "반납 3일 전과 당일에 알려드려요", + checked = true, + onCheckedChange = {}, + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/textfield/BookOnPasswordField.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/textfield/BookOnPasswordField.kt new file mode 100644 index 0000000..6e13d85 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/textfield/BookOnPasswordField.kt @@ -0,0 +1,130 @@ +package com.example.bookon.ui.commonComponent.textfield + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.res.stringResource +import com.example.bookon.R +import com.example.bookon.theme.AppComponentSize +import com.example.bookon.theme.AppSpacing +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnPasswordFieldUiState + +/** + * 비밀번호 표시 전환을 포함한 입력 필드이다. + * trailingIcon을 직접 넘기지 않아도 텍스트 토글로 표시 상태를 제어한다. + */ +@Composable +fun BookOnPasswordField( + uiState: BookOnPasswordFieldUiState, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + visibleLabel: String? = null, + hiddenLabel: String? = null, +) { + BookOnPasswordField( + value = uiState.value, + onValueChange = onValueChange, + modifier = modifier, + label = uiState.label, + placeholder = uiState.placeholder, + errorText = uiState.errorText, + enabled = uiState.enabled, + visibleLabel = visibleLabel, + hiddenLabel = hiddenLabel, + ) +} + +/** + * 비밀번호 표시 전환을 포함한 입력 필드이다. + * trailingIcon을 직접 넘기지 않아도 텍스트 토글로 표시 상태를 제어한다. + */ +@Composable +fun BookOnPasswordField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + placeholder: String = "", + errorText: String? = null, + enabled: Boolean = true, + visibleLabel: String? = null, + hiddenLabel: String? = null, +) { + var passwordVisible by rememberSaveable { mutableStateOf(false) } + val showPasswordText = hiddenLabel ?: stringResource(R.string.action_show_password) + val hidePasswordText = visibleLabel ?: stringResource(R.string.action_hide_password) + val toggleDescription = stringResource(R.string.password_visibility_toggle_description) + + BookOnTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + label = label, + placeholder = placeholder, + errorText = errorText, + enabled = enabled, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + Box( + modifier = Modifier + .sizeIn( + minWidth = AppComponentSize.MinTouchTarget, + minHeight = AppComponentSize.MinTouchTarget, + ) + .semantics { + contentDescription = toggleDescription + } + .clickable( + enabled = enabled, + role = Role.Button, + onClick = { passwordVisible = !passwordVisible }, + ) + .padding(horizontal = AppSpacing.Small), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (passwordVisible) hidePasswordText else showPasswordText, + style = BookOnTypography.caption, + color = BookOnColor.TextPlaceholder, + ) + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun BookOnPasswordFieldPreview() { + BookOnTheme { + BookOnPasswordField( + value = "password", + onValueChange = {}, + label = "비밀번호", + ) + } +} diff --git a/app/src/main/java/com/example/bookon/ui/commonComponent/textfield/BookOnTextFieldInput.kt b/app/src/main/java/com/example/bookon/ui/commonComponent/textfield/BookOnTextFieldInput.kt new file mode 100644 index 0000000..b3be390 --- /dev/null +++ b/app/src/main/java/com/example/bookon/ui/commonComponent/textfield/BookOnTextFieldInput.kt @@ -0,0 +1,231 @@ +package com.example.bookon.ui.commonComponent.textfield + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import com.example.bookon.theme.AppComponentSize +import com.example.bookon.theme.AppElevation +import com.example.bookon.theme.AppIconSize +import com.example.bookon.theme.AppRadius +import com.example.bookon.theme.AppSpacing +import com.example.bookon.theme.BookOnColor +import com.example.bookon.theme.BookOnTheme +import com.example.bookon.theme.BookOnTypography +import com.example.bookon.uiState.BookOnTextFieldUiState + +/** + * 라벨, 아이콘, 오류 문구를 포함한 BookOn 입력 필드이다. + * 화면은 value와 onValueChange만 전달하고 검증 결과는 isError와 errorText로 표현한다. + */ +@Composable +fun BookOnTextField( + uiState: BookOnTextFieldUiState, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + singleLine: Boolean = true, + textStyle: TextStyle = BookOnTypography.fieldText, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, +) { + BookOnTextField( + value = uiState.value, + onValueChange = onValueChange, + modifier = modifier, + label = uiState.label, + placeholder = uiState.placeholder, + errorText = uiState.errorText, + isError = uiState.isError, + enabled = uiState.enabled, + singleLine = singleLine, + textStyle = textStyle, + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + ) +} + +/** + * 라벨, 아이콘, 오류 문구를 포함한 BookOn 입력 필드이다. + * 화면은 value와 onValueChange만 전달하고 검증 결과는 isError와 errorText로 표현한다. + */ +@Composable +fun BookOnTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + placeholder: String = "", + errorText: String? = null, + isError: Boolean = errorText != null, + enabled: Boolean = true, + singleLine: Boolean = true, + textStyle: TextStyle = BookOnTypography.fieldText, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, +) { + val shape = RoundedCornerShape(AppRadius.Field) + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor by animateColorAsState( + targetValue = when { + isError -> BookOnColor.Error + isFocused -> BookOnColor.Primary + else -> BookOnColor.Surface + }, + ) + val containerColor by animateColorAsState( + targetValue = if (isError) BookOnColor.ErrorContainer else BookOnColor.Surface, + ) + + Column(modifier = modifier.fillMaxWidth()) { + if (label != null) { + Text( + text = label, + style = BookOnTypography.fieldLabel, + color = BookOnColor.TextPrimary, + ) + Spacer(modifier = Modifier.height(AppSpacing.Small)) + } + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .height(AppComponentSize.FieldHeight) + .shadow( + elevation = AppElevation.Field, + shape = shape, + ) + .clip(shape) + .background(containerColor) + .border( + width = AppComponentSize.FieldBorderWidth, + color = borderColor, + shape = shape, + ) + .semantics { + if (isError && errorText != null) error(errorText) + }, + enabled = enabled, + singleLine = singleLine, + textStyle = textStyle.copy(color = BookOnColor.TextPrimary), + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation, + cursorBrush = SolidColor(BookOnColor.Primary), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = AppSpacing.FieldHorizontal), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leadingIcon != null) { + Box( + modifier = Modifier.size(AppIconSize.Small), + contentAlignment = Alignment.Center, + ) { + leadingIcon() + } + Spacer(modifier = Modifier.width(AppSpacing.Content)) + } + + Box(modifier = Modifier.weight(1f)) { + if (value.isEmpty()) { + Text( + text = placeholder, + style = BookOnTypography.fieldPlaceholder, + color = BookOnColor.TextPlaceholder, + ) + } + innerTextField() + } + + if (trailingIcon != null) { + Spacer(modifier = Modifier.width(AppSpacing.Item)) + Box( + modifier = Modifier.sizeIn( + minWidth = AppComponentSize.MinTouchTarget, + minHeight = AppComponentSize.MinTouchTarget, + ), + contentAlignment = Alignment.Center, + ) { + trailingIcon() + } + } + } + }, + ) + + if (errorText != null) { + Spacer(modifier = Modifier.height(AppSpacing.Small)) + Text( + text = errorText, + style = BookOnTypography.caption, + color = BookOnColor.Error, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnTextFieldPreview() { + BookOnTheme { + BookOnTextField( + value = "", + onValueChange = {}, + label = "학교 이메일", + placeholder = "이메일 주소", + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BookOnTextFieldErrorPreview() { + BookOnTheme { + BookOnTextField( + value = "student", + onValueChange = {}, + label = "학교 이메일", + errorText = "올바른 이메일 형식이 아니에요", + ) + } +} diff --git a/app/src/main/java/com/example/bookon/uiState/BookOnBarsUiState.kt b/app/src/main/java/com/example/bookon/uiState/BookOnBarsUiState.kt new file mode 100644 index 0000000..5e17c53 --- /dev/null +++ b/app/src/main/java/com/example/bookon/uiState/BookOnBarsUiState.kt @@ -0,0 +1,22 @@ +package com.example.bookon.uiState + +import androidx.compose.runtime.Immutable +import com.example.bookon.ui.commonComponent.bar.BookOnNavigationItem + +@Immutable +data class BookOnTopBarUiState( + val title: String, + val backContentDescription: String = "뒤로가기", +) + +@Immutable +data class BookOnStepProgressUiState( + val currentStep: Int, + val totalStep: Int, +) + +@Immutable +data class BookOnBottomNavigationUiState( + val items: List, + val selectedIndex: Int, +) diff --git a/app/src/main/java/com/example/bookon/uiState/BookOnButtonUiState.kt b/app/src/main/java/com/example/bookon/uiState/BookOnButtonUiState.kt new file mode 100644 index 0000000..6281aa2 --- /dev/null +++ b/app/src/main/java/com/example/bookon/uiState/BookOnButtonUiState.kt @@ -0,0 +1,10 @@ +package com.example.bookon.uiState + +import androidx.compose.runtime.Immutable + +@Immutable +data class BookOnPrimaryButtonUiState( + val text: String, + val enabled: Boolean = true, + val loading: Boolean = false, +) diff --git a/app/src/main/java/com/example/bookon/uiState/BookOnCardsUiState.kt b/app/src/main/java/com/example/bookon/uiState/BookOnCardsUiState.kt new file mode 100644 index 0000000..00adec5 --- /dev/null +++ b/app/src/main/java/com/example/bookon/uiState/BookOnCardsUiState.kt @@ -0,0 +1,46 @@ +package com.example.bookon.uiState + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.example.bookon.ui.commonComponent.card.BookOnStatItem +import com.example.bookon.theme.BookOnColor + +@Immutable +data class BookOnStatSummaryCardUiState( + val items: List, +) + +@Immutable +data class BookOnInfoCardUiState( + val title: String, + val description: String? = null, + val containerColor: Color = BookOnColor.Background, + val borderColor: Color = BookOnColor.SurfaceBorder, +) + +@Immutable +data class BookOnMenuRowUiState( + val title: String, + val destructive: Boolean = false, + val showDivider: Boolean = true, +) + +@Immutable +data class BookOnFilterChipUiState( + val text: String, + val selected: Boolean, +) + +@Immutable +data class BookOnSwitchRowUiState( + val title: String, + val checked: Boolean, + val description: String? = null, +) + +@Immutable +data class BookOnBookCardUiState( + val title: String, + val author: String, + val coverImageUrl: String? = null, +) diff --git a/app/src/main/java/com/example/bookon/uiState/BookOnTextFieldUiState.kt b/app/src/main/java/com/example/bookon/uiState/BookOnTextFieldUiState.kt new file mode 100644 index 0000000..4a556b9 --- /dev/null +++ b/app/src/main/java/com/example/bookon/uiState/BookOnTextFieldUiState.kt @@ -0,0 +1,22 @@ +package com.example.bookon.uiState + +import androidx.compose.runtime.Immutable + +@Immutable +data class BookOnTextFieldUiState( + val value: String, + val label: String? = null, + val placeholder: String = "", + val errorText: String? = null, + val isError: Boolean = errorText != null, + val enabled: Boolean = true, +) + +@Immutable +data class BookOnPasswordFieldUiState( + val value: String, + val label: String? = null, + val placeholder: String = "", + val errorText: String? = null, + val enabled: Boolean = true, +) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/navigation_home.png b/app/src/main/res/drawable/navigation_home.png new file mode 100644 index 0000000..2823923 Binary files /dev/null and b/app/src/main/res/drawable/navigation_home.png differ diff --git a/app/src/main/res/drawable/navigation_library.png b/app/src/main/res/drawable/navigation_library.png new file mode 100644 index 0000000..bdb983c Binary files /dev/null and b/app/src/main/res/drawable/navigation_library.png differ diff --git a/app/src/main/res/drawable/navigation_my.png b/app/src/main/res/drawable/navigation_my.png new file mode 100644 index 0000000..43c9a16 Binary files /dev/null and b/app/src/main/res/drawable/navigation_my.png differ diff --git a/app/src/main/res/drawable/navigation_rank.png b/app/src/main/res/drawable/navigation_rank.png new file mode 100644 index 0000000..ded3c99 Binary files /dev/null and b/app/src/main/res/drawable/navigation_rank.png differ diff --git a/app/src/main/res/font/pretendard_black.otf b/app/src/main/res/font/pretendard_black.otf new file mode 100644 index 0000000..a0d849e Binary files /dev/null and b/app/src/main/res/font/pretendard_black.otf differ diff --git a/app/src/main/res/font/pretendard_bold.otf b/app/src/main/res/font/pretendard_bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/app/src/main/res/font/pretendard_bold.otf differ diff --git a/app/src/main/res/font/pretendard_extra_bold.otf b/app/src/main/res/font/pretendard_extra_bold.otf new file mode 100644 index 0000000..388f3ca Binary files /dev/null and b/app/src/main/res/font/pretendard_extra_bold.otf differ diff --git a/app/src/main/res/font/pretendard_extra_light.otf b/app/src/main/res/font/pretendard_extra_light.otf new file mode 100644 index 0000000..40c8b69 Binary files /dev/null and b/app/src/main/res/font/pretendard_extra_light.otf differ diff --git a/app/src/main/res/font/pretendard_light.otf b/app/src/main/res/font/pretendard_light.otf new file mode 100644 index 0000000..228679e Binary files /dev/null and b/app/src/main/res/font/pretendard_light.otf differ diff --git a/app/src/main/res/font/pretendard_medium.otf b/app/src/main/res/font/pretendard_medium.otf new file mode 100644 index 0000000..0575069 Binary files /dev/null and b/app/src/main/res/font/pretendard_medium.otf differ diff --git a/app/src/main/res/font/pretendard_regular.otf b/app/src/main/res/font/pretendard_regular.otf new file mode 100644 index 0000000..08bf4cf Binary files /dev/null and b/app/src/main/res/font/pretendard_regular.otf differ diff --git a/app/src/main/res/font/pretendard_semi_bold.otf b/app/src/main/res/font/pretendard_semi_bold.otf new file mode 100644 index 0000000..e7e36ab Binary files /dev/null and b/app/src/main/res/font/pretendard_semi_bold.otf differ diff --git a/app/src/main/res/font/pretendard_thin.otf b/app/src/main/res/font/pretendard_thin.otf new file mode 100644 index 0000000..77e792d Binary files /dev/null and b/app/src/main/res/font/pretendard_thin.otf differ diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..63ccc9e --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,174 @@ + + BookOn + + + 다음 + 확인 + 가입 완료 + 시작하기 + 더보기 + 자세히 보기 + 검색 + 인증하고 계속하기 + 로그아웃 + 보기 + 숨김 + 처리 중 + 비밀번호 표시 전환 + 가능 + 연동됨 + 참여 중 + + + 환영합니다 ! + 로그인 + 회원가입 + 회원가입 하러가기 + 비밀번호를 잊으셨나요? + 올바른 이메일 형식이 아니에요 + 이메일이 옳지 않습니다 + 비밀번호는 6자 이상이어야 해요 + + + STEP 1 / 3 + STEP 2 / 3 + STEP 3 / 3 + 학교 정보 + 계정 정보 + 학교 계정으로 간편하게 가입하세요! + 학교 이메일 + 이메일 주소 + @gsm.hs.kr + 이름 + 성별 + 남자 + 여자 + 학과 + 소프트웨어 개발과 + IoT과 + AI과 + 비밀번호 + 비밀번호 확인 + 비밀번호 주의사항 + 비밀번호 유의사항 + 영문(대·소문자), 숫자\n특수문자를 포함한 6~15자를 입력 + 비밀번호를 설정해주세요 + 비밀번호 주의사항을 확인해주세요 + 비밀번호를 다시 확인해주세요 + 개인정보 수집 및 이용 안내 + 개인정보 수집 및 이용에 동의합니다 (필수) + 1. 수집 항목 + 이름, 이메일 주소, 비밀번호 + 2. 수집 목적 + 회원 식별 및 관리 + 서비스 제공 및 공지사항 전달 + 3. 보유 및 이용 기간 + 회원 탈퇴 시까지 + 4.이용약관 안내 + 서버 점검이나 업데이트 시\n 서비스가 일시 중단될 수 있음 + 이용자는 개인정보 수집 및 이용에 대한 동의를 거부할 권리가 있으\n며, 동의를 거부할 경우 회원가입이 제한될 수 있습니다. + 가입 완료! + %1$s 님, 환영해요.\n이제 Book - on에서 마음껏 읽어보세요. + 보유 도서 + 인증번호 입력 + %1$s 으로 보낸\n6자리 코드를 입력해 주세요 + %1$s 후 만료 + 코드를 받지 못하셨나요? 재전송 + + + 독서마라톤 + 계정연동 + 독서마라톤 계정을 연동하면\n읽은 책이 자동으로 기록돼요 + 독서마라톤 아이디와 비밀번호를\n입력해 주세요 + 독서마라톤 이용하기 + 교내 독서마라톤에 참여 중이라면 연동\n하세요 + 연동하면 홈 · 마이페이지에서 진척도와 랭킹이 자동으로\n표시돼요. + 지금 연동하지 않아도 괜찮아요.\n마이페이지에서 언제든 다시 연동할 수 있어요! + 독서마라톤 아이디 + 독서마라톤 개인정보 제공 동의 체크 + 나중에 할게요 · 건너뛰기 + 연동하고 가입완료 + + + 좋은 저녁이에요 + %1$s님 + 도서 찾기 + 책 제목, 저자, 도서관 번호 + 도서 찾기 + 도서 검색 + 알림 + 프로필 + 도서부 공지 + NEW + %1$s · 도서부 + 여름방학 도서 대출 기간 연장 안내 + 방학 기간 동안 1인당 최대 5권, 대출 기간이 14일로 연장됩니다.\n반납은 개학일 전까지 완료해 주세요. + AI 추천 + AI + %1$s님의 대출 이력을 분석해 골랐어요 + 우리 학교 인기 책 + 최근 새로 들어온 도서 + + + + 랭킹 + 도서실 + 마이 + + + 도서관 번호 + 재고 수량 + 대출 여부 + 책 소개 + 대출 신청하기 + 대출 불가 + %1$s 검색 결과 %2$d건 · 제목 · 도서관 번호 + 검색된 책이 없어요\n다른 검색어를 입력해보세요 + + + 전체 + 다독왕 랭킹 + 2026년 · 대출 권수 기준 · 매년 1월 1일 초기화 + %1$d권 + %1$d 권 + %1$d학년 · %2$s + + + 인기순 + 신간순 + 전체 + 소설 + 과학 + 역사 + 개발 + + + 내 서재 + %1$s 님 + %1$d기 · %2$s + 대출 중 + 반납 임박 + 누적 대출 + %1$d권 + 2026 독서마라톤 + %1$s · %2$d / %3$d권 + %1$d%% + 아직 연동하지 않았어요 + 완주까지 %1$d권 남았어요 · 상위 %2$d%% + 토글을 켜면 독서마라톤 계정을 연동할 수 있어요 + 비밀번호 변경 + 대출 / 반납 내역 + 즐겨찾기 목록 + 알림 설정 + 이용 안내 + 받고 싶은 알림을 선택하세요 + 도서부 공지 알림 + 새 공지가 올라오면 알려드려요 + 대출 중 %1$d + 반납 완료 + 반납 알림 + 반납 3일 전과 당일에 알려드려요 + D - %1$d + %1$s · 반납 %2$s + 지난 대출 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8b2a0b5 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +