From 53a5f0522a143e80dfdb60e0fd8e0ed6f71ee433 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Wed, 22 Apr 2026 14:22:43 +0900 Subject: [PATCH 01/99] =?UTF-8?q?refactor:=20Jetpack=20Compose=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20#489?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feature/profile/build.gradle.kts | 20 ++++++++++++++++++++ gradle/libs.versions.toml | 26 ++++++++++++++++++++++++++ presentation/build.gradle.kts | 23 +++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index 4cc8c496..5a53e684 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -10,6 +10,14 @@ android { defaultConfig { manifestPlaceholders["appAuthRedirectScheme"] = "com.project200.undabang" } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } } dependencies { @@ -18,6 +26,18 @@ dependencies { implementation(projects.presentation) implementation(projects.core.oauth) + // Compose + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.activity.compose) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.compose) + debugImplementation(libs.compose.ui.tooling) + implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) implementation(libs.google.android.material) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8efd7b12..f50bc683 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,10 @@ gradlePlugin = "8.3.2" kotlin = "1.9.23" ksp = "1.9.23-1.0.19" + +# Compose +composeBom = "2024.02.02" +composeCompiler = "1.5.13" # Kotlin 1.9.23 호환 필수 compileSdk = "35" minSdk = "26" targetSdk = "35" @@ -89,6 +93,28 @@ tedClustering = "1.0.2" [libraries] +# Compose BOM & Core +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } + +# Compose Integration +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } + +# Compose Testing +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } + +# Image Loading (Coil) +coil-compose = { group = "io.coil-kt", name = "coil-compose", version = "2.6.0" } + # Gradle 플러그인 아티팩트 정의 android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "gradlePlugin" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 1e9245f1..39d4e3d8 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -9,6 +9,11 @@ android { buildFeatures { viewBinding = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } } @@ -16,6 +21,24 @@ dependencies { implementation(projects.domain) implementation(projects.common) + // Compose + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.activity.compose) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.compose) + debugImplementation(libs.compose.ui.tooling) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test.junit4) + debugImplementation(libs.compose.ui.test.manifest) + + // Image Loading + implementation(libs.coil.compose) + implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.splashscreen) From e5b8df5183d0828c3c16e90e62805536f0db64be Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Wed, 22 Apr 2026 14:22:43 +0900 Subject: [PATCH 02/99] =?UTF-8?q?refactor:=20Compose=20Theme=20(Color,=20T?= =?UTF-8?q?ypography,=20Shape)=20=EC=B6=94=EA=B0=80=20#489?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/compose/theme/Color.kt | 30 +++ .../presentation/compose/theme/Shape.kt | 12 + .../presentation/compose/theme/Theme.kt | 63 +++++ .../compose/theme/ThemePreview.kt | 227 ++++++++++++++++++ .../presentation/compose/theme/Type.kt | 76 ++++++ 5 files changed, 408 insertions(+) create mode 100644 presentation/src/main/java/com/project200/presentation/compose/theme/Color.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/theme/Shape.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/theme/Theme.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/theme/ThemePreview.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/theme/Type.kt diff --git a/presentation/src/main/java/com/project200/presentation/compose/theme/Color.kt b/presentation/src/main/java/com/project200/presentation/compose/theme/Color.kt new file mode 100644 index 00000000..81a1c735 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/theme/Color.kt @@ -0,0 +1,30 @@ +package com.project200.presentation.compose.theme + +import androidx.compose.ui.graphics.Color + +// 기본 색상 - XML colors.xml에서 매핑 +val ColorMain = Color(0xFF4F7942) +val ColorMainBackground = Color(0xFFEBF1E5) +val ColorErrorRed = Color(0xFFE53935) + +val ColorWhite300 = Color(0xFFFFFFFF) +val ColorWhite200 = Color(0xFFFAFAFA) +val ColorWhite100 = Color(0xFFF4F4F4) + +val ColorGray100 = Color(0xFF6E6E6E) +val ColorGray200 = Color(0xFF9E9E9E) +val ColorGray300 = Color(0xFFE0E0E0) +val ColorGrayTrans = Color(0x33E0E0E0) + +val ColorBlack = Color(0xFF212121) + +// 점수 레벨 +val ColorScoreHighLevel = Color(0xFFE53935) +val ColorScoreMiddleLevel = Color(0xFFFD8027) +val ColorScoreLowLevel = Color(0xFF9E9E9E) + +// 추가 색상 (Compose Material3 호환) +val ColorSurface = ColorWhite300 +val ColorBackground = ColorMainBackground +val ColorSurfaceVariant = ColorGray300 +val ColorOutline = ColorGray200 diff --git a/presentation/src/main/java/com/project200/presentation/compose/theme/Shape.kt b/presentation/src/main/java/com/project200/presentation/compose/theme/Shape.kt new file mode 100644 index 00000000..4ea31053 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/theme/Shape.kt @@ -0,0 +1,12 @@ +package com.project200.presentation.compose.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val AppShapes = Shapes( + small = RoundedCornerShape(5.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(15.dp), + extraLarge = RoundedCornerShape(15.dp), +) diff --git a/presentation/src/main/java/com/project200/presentation/compose/theme/Theme.kt b/presentation/src/main/java/com/project200/presentation/compose/theme/Theme.kt new file mode 100644 index 00000000..77ee75aa --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/theme/Theme.kt @@ -0,0 +1,63 @@ +package com.project200.presentation.compose.theme + +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val LightColorScheme = ColorScheme( + primary = ColorMain, + onPrimary = ColorWhite300, + primaryContainer = ColorMainBackground, + onPrimaryContainer = ColorMain, + inversePrimary = ColorWhite300, + secondary = ColorGray200, + onSecondary = ColorWhite300, + secondaryContainer = ColorGray300, + onSecondaryContainer = ColorGray100, + tertiary = ColorMain, + onTertiary = ColorWhite300, + tertiaryContainer = ColorMainBackground, + onTertiaryContainer = ColorMain, + error = ColorErrorRed, + onError = ColorWhite300, + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = ColorErrorRed, + background = ColorBackground, + onBackground = ColorBlack, + surface = ColorSurface, + onSurface = ColorBlack, + surfaceVariant = ColorSurfaceVariant, + onSurfaceVariant = ColorGray100, + surfaceTint = ColorMain, + inverseSurface = ColorBlack, + inverseOnSurface = ColorWhite300, + outline = ColorOutline, + outlineVariant = ColorGray300, + scrim = Color(0xFF000000) +) + +@Composable +fun AppTheme( + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + dynamicLightColorScheme(context) + } + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content + ) +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/theme/ThemePreview.kt b/presentation/src/main/java/com/project200/presentation/compose/theme/ThemePreview.kt new file mode 100644 index 00000000..c78c71e9 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/theme/ThemePreview.kt @@ -0,0 +1,227 @@ +package com.project200.presentation.compose.theme + +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.layout.padding +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Preview(showBackground = true) +@Composable +fun ColorPalettePreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "컬러 팔레트", + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + + ColorRow("Main Color", ColorMain) + ColorRow("Main Background", ColorMainBackground) + ColorRow("Error Red", ColorErrorRed) + ColorRow("White 300", ColorWhite300) + ColorRow("White 200", ColorWhite200) + ColorRow("White 100", ColorWhite100) + ColorRow("Gray 100", ColorGray100) + ColorRow("Gray 200", ColorGray200) + ColorRow("Gray 300", ColorGray300) + ColorRow("Black", ColorBlack) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "점수 레벨", + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + ColorRow("High Level", ColorScoreHighLevel) + ColorRow("Middle Level", ColorScoreMiddleLevel) + ColorRow("Low Level", ColorScoreLowLevel) + } + } +} + +@Composable +private fun ColorRow(label: String, color: Color) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = color, + shape = RoundedCornerShape(8.dp) + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "#${color.toArgb().toUInt().toString(16).uppercase().takeLast(6)}", + color = Color.White, + fontSize = 12.sp + ) + } +} + +@Preview(showBackground = true) +@Composable +fun TypographyPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "타이포그래피", + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + + Text( + text = "Display Large (22sp, Bold)", + style = AppTypography.displayLarge + ) + + Text( + text = "Display Medium (18sp, Bold)", + style = AppTypography.displayMedium + ) + + Text( + text = "Body Large (15sp, Normal)", + style = AppTypography.bodyLarge + ) + + Text( + text = "Body Medium (14sp, Normal)", + style = AppTypography.bodyMedium + ) + + Text( + text = "Body Small (12sp, Normal)", + style = AppTypography.bodySmall + ) + + Text( + text = "Label Large (15sp, SemiBold)", + style = AppTypography.labelLarge + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ShapesPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "쉐이프/코너", + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(60.dp) + .background( + color = ColorMain, + shape = AppShapes.small + ) + ) + Text("Small (5dp)") + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(60.dp) + .background( + color = ColorMain, + shape = AppShapes.medium + ) + ) + Text("Medium (8dp)") + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(60.dp) + .background( + color = ColorMain, + shape = AppShapes.large + ) + ) + Text("Large (12dp)") + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(60.dp) + .background( + color = ColorMain, + shape = AppShapes.extraLarge + ) + ) + Text("ExtraLarge (16dp)") + } + } + } +} + +private fun Color.toArgb(): Int { + return ((alpha * 255).toInt() shl 24) or + ((red * 255).toInt() shl 16) or + ((green * 255).toInt() shl 8) or + (blue * 255).toInt() +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/theme/Type.kt b/presentation/src/main/java/com/project200/presentation/compose/theme/Type.kt new file mode 100644 index 00000000..ea6d89bc --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/theme/Type.kt @@ -0,0 +1,76 @@ +package com.project200.presentation.compose.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.project200.undabang.presentation.R + +val PretendardFontFamily = FontFamily( + Font(R.font.pretendard_bold, FontWeight.Bold), + Font(R.font.pretendard_medium, FontWeight.Medium), + Font(R.font.pretendard_regular, FontWeight.Normal), +) + +val AppTypography = Typography( + displayLarge = TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + lineHeight = 26.4.sp, + ), + displayMedium = TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + lineHeight = 21.6.sp, + ), + titleMedium = TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + lineHeight = 18.sp, + ), + bodyLarge = TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + lineHeight = 18.sp, + ), + bodyMedium = TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + lineHeight = 16.8.sp, + ), + bodySmall = TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + lineHeight = 14.4.sp, + ), + labelSmall = TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 10.sp, + fontWeight = FontWeight.Normal, + lineHeight = 12.sp, + ), + labelLarge = TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 18.sp, + ), +) + +// XML styles.xml 이름으로 접근할 수 있는 확장 프로퍼티 +// 사용: MaterialTheme.typography.header +val Typography.header: TextStyle get() = displayLarge +val Typography.mediumBold: TextStyle get() = displayMedium +val Typography.contentBold: TextStyle get() = titleMedium +val Typography.contentRegular: TextStyle get() = bodyLarge +val Typography.subtext14: TextStyle get() = bodyMedium +val Typography.subtext12: TextStyle get() = bodySmall +val Typography.subtext10: TextStyle get() = labelSmall From b9917978be1fbe6bdc9320326186d1f2f7184725 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Wed, 22 Apr 2026 14:22:43 +0900 Subject: [PATCH 03/99] =?UTF-8?q?refactor:=20Compose=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20UI=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(Button,=20TextField,=20AlertDialog,=20TopBar,=20Av?= =?UTF-8?q?atar,=20Badge)=20#489?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/components/button/Button.kt | 108 +++++++++ .../components/button/ButtonPreview.kt | 122 ++++++++++ .../compose/components/display/Avatar.kt | 109 +++++++++ .../compose/components/display/Badge.kt | 59 +++++ .../components/display/DisplayPreview.kt | 213 ++++++++++++++++++ .../components/feedback/AlertDialog.kt | 162 +++++++++++++ .../components/feedback/AlertDialogPreview.kt | 86 +++++++ .../compose/components/input/TextField.kt | 181 +++++++++++++++ .../components/input/TextFieldPreview.kt | 175 ++++++++++++++ .../compose/components/layout/TopBar.kt | 103 +++++++++ .../components/layout/TopBarPreview.kt | 64 ++++++ 11 files changed, 1382 insertions(+) create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/button/ButtonPreview.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialogPreview.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBarPreview.kt diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt b/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt new file mode 100644 index 00000000..49033a24 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt @@ -0,0 +1,108 @@ +package com.project200.presentation.compose.components.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorGray300 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite100 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.subtext14 + +@Composable +fun PrimaryButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + enabled: Boolean = true, + isCompact: Boolean = false +) { + Button( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(if (isCompact) 36.dp else 55.dp), + colors = ButtonDefaults.buttonColors( + containerColor = ColorMain, + contentColor = ColorWhite300, + disabledContainerColor = ColorGray200, + disabledContentColor = ColorWhite300, + ), + shape = MaterialTheme.shapes.large, + enabled = enabled, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = if (isCompact) 8.dp else 12.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +fun SecondaryButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + enabled: Boolean = true, + isCompact: Boolean = false +) { + Button( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(if (isCompact) 36.dp else 55.dp), + colors = ButtonDefaults.buttonColors( + containerColor = ColorGray300, + contentColor = ColorWhite300, + disabledContainerColor = ColorGray200, + disabledContentColor = ColorWhite300, + ), + shape = MaterialTheme.shapes.large, + enabled = enabled, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = if (isCompact) 8.dp else 12.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +fun TextButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + enabled: Boolean = true, + isCompact: Boolean = true +) { + OutlinedButton( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(if (isCompact) 32.dp else 44.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = ColorWhite100, + contentColor = ColorMain, + disabledContentColor = ColorGray200 + ), + shape = MaterialTheme.shapes.small, + enabled = enabled, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = if (isCompact) 4.dp else 8.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.subtext14, + ) + } +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/button/ButtonPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/button/ButtonPreview.kt new file mode 100644 index 00000000..cd4e3405 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/button/ButtonPreview.kt @@ -0,0 +1,122 @@ +package com.project200.presentation.compose.components.button + +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.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.theme.AppTheme + +@Preview(showBackground = true) +@Composable +fun PrimaryButtonPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + PrimaryButton( + text = "완료", + onClick = {} + ) + Spacer(modifier = Modifier.height(12.dp)) + PrimaryButton( + text = "완료", + onClick = {}, + enabled = false + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun PrimaryButtonCompactPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + PrimaryButton( + text = "완료", + onClick = {}, + isCompact = true + ) + Spacer(modifier = Modifier.height(12.dp)) + PrimaryButton( + text = "완료", + onClick = {}, + isCompact = true, + enabled = false + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun SecondaryButtonPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + SecondaryButton( + text = "취소", + onClick = {} + ) + Spacer(modifier = Modifier.height(12.dp)) + SecondaryButton( + text = "취소", + onClick = {}, + enabled = false + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun TextButtonPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + TextButton( + text = "중복 확인", + onClick = {} + ) + Spacer(modifier = Modifier.height(12.dp)) + TextButton( + text = "중복 확인", + onClick = {}, + enabled = false + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ButtonGroupPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + PrimaryButton(text = "확인", onClick = {}) + Spacer(modifier = Modifier.height(8.dp)) + SecondaryButton(text = "취소", onClick = {}) + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt new file mode 100644 index 00000000..b09812b4 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt @@ -0,0 +1,109 @@ +package com.project200.presentation.compose.components.display + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorGray300 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite300 + +@Composable +fun UndabangAvatar( + imageUrl: String, + modifier: Modifier = Modifier, + size: Dp = 48.dp, + borderColor: Color = ColorMain, + borderWidth: Dp = 2.dp, + contentDescription: String = "프로필 이미지", +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .border( + width = borderWidth, + color = borderColor, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = imageUrl, + contentDescription = contentDescription, + modifier = Modifier + .size(size - borderWidth * 2) + .clip(CircleShape), + contentScale = ContentScale.Crop, + placeholder = null, + error = null, + fallback = null, + ) + } +} + +@Composable +fun UndabangPlaceholderAvatar( + modifier: Modifier = Modifier, + size: Dp = 48.dp, + backgroundColor: Color = ColorGray300, + borderColor: Color = ColorMain, + borderWidth: Dp = 2.dp, +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(color = backgroundColor, shape = CircleShape) + .border( + width = borderWidth, + color = borderColor, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "기본 프로필", + modifier = Modifier.size(size * 0.5f), + tint = ColorGray200, + ) + } +} + +@Composable +fun UndabangCard( + modifier: Modifier = Modifier, + backgroundColor: Color = ColorWhite300, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = backgroundColor, + ), + shape = MaterialTheme.shapes.medium, + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + ), + ) { + content() + } +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt new file mode 100644 index 00000000..03569fdb --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt @@ -0,0 +1,59 @@ +package com.project200.presentation.compose.components.display + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.project200.presentation.compose.theme.ColorErrorRed +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite300 + +@Composable +fun UndabangBadge( + count: Int, + modifier: Modifier = Modifier, + backgroundColor: Color = ColorErrorRed, + contentColor: Color = ColorWhite300, + size: androidx.compose.ui.unit.Dp = 24.dp +) { + Box( + modifier = modifier + .size(size) + .background( + color = backgroundColor, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (count > 99) "99+" else count.toString(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = contentColor + ) + } +} + +@Composable +fun UndabangDot( + modifier: Modifier = Modifier, + color: Color = ColorErrorRed, + size: androidx.compose.ui.unit.Dp = 12.dp +) { + Box( + modifier = modifier + .size(size) + .background( + color = color, + shape = CircleShape + ) + ) +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt new file mode 100644 index 00000000..521b3186 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt @@ -0,0 +1,213 @@ +package com.project200.presentation.compose.components.display + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorGray300 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite300 + +@Preview(showBackground = true) +@Composable +fun AvatarPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("프로필 아바타", fontWeight = FontWeight.Bold, fontSize = 16.sp) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + UndabangPlaceholderAvatar(size = 32.dp) + UndabangPlaceholderAvatar(size = 48.dp) + UndabangPlaceholderAvatar(size = 64.dp) + } + + Text("다양한 크기", fontWeight = FontWeight.Bold, fontSize = 14.sp) + } + } +} + +@Preview(showBackground = true) +@Composable +fun CardPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("카드 컴포넌트", fontWeight = FontWeight.Bold, fontSize = 16.sp) + + UndabangCard( + backgroundColor = ColorWhite300 + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "운동 종류", + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "헬스", + fontSize = 12.sp, + color = Color.Gray + ) + } + } + + UndabangCard( + backgroundColor = ColorMain.copy(alpha = 0.1f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "점수", + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "85점", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = ColorMain + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun BadgePreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("뱃지 컴포넌트", fontWeight = FontWeight.Bold, fontSize = 16.sp) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + UndabangBadge(count = 1) + UndabangBadge(count = 5) + UndabangBadge(count = 99) + UndabangBadge(count = 100) + } + + Text("카운트별 뱃지", fontSize = 12.sp, color = Color.Gray) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DotPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("도트 표시기", fontWeight = FontWeight.Bold, fontSize = 16.sp) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + UndabangDot(size = 8.dp) + UndabangDot(size = 12.dp) + UndabangDot(size = 16.dp) + } + + Text("크기별 도트", fontSize = 12.sp, color = Color.Gray) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DisplayComponentsGroupPreview() { + AppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .background(ColorGray300) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Display 컴포넌트 조합", fontWeight = FontWeight.Bold, fontSize = 16.sp) + + Row( + modifier = Modifier + .fillMaxWidth() + .background(ColorWhite300, shape = androidx.compose.material3.MaterialTheme.shapes.medium) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + UndabangPlaceholderAvatar(size = 48.dp) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "사용자 이름", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = "최근 활동: 30분 전", + fontSize = 12.sp, + color = Color.Gray + ) + } + + UndabangBadge(count = 3) + } + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt new file mode 100644 index 00000000..5cf048b8 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt @@ -0,0 +1,162 @@ +package com.project200.presentation.compose.components.feedback + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.button.SecondaryButton +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.mediumBold +import com.project200.presentation.compose.theme.subtext14 + +@Composable +fun UndabangAlertDialog( + title: String, + message: String, + modifier: Modifier = Modifier, + confirmText: String = "확인", + cancelText: String = "취소", + onConfirm: () -> Unit, + onCancel: (() -> Unit)? = null, + onDismiss: () -> Unit, + isCancelable: Boolean = true +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = isCancelable, + dismissOnClickOutside = isCancelable + ) + ) { + Surface( + modifier = modifier + .fillMaxWidth(0.85f), + shape = MaterialTheme.shapes.large, + color = ColorWhite300 + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.mediumBold, + color = ColorBlack, + textAlign = TextAlign.Center, + ) + + Text( + text = message, + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + textAlign = TextAlign.Center, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (onCancel != null) { + SecondaryButton( + text = cancelText, + modifier = Modifier.weight(1f), + onClick = { + onCancel() + onDismiss() + }, + isCompact = true + ) + } + + PrimaryButton( + text = confirmText, + modifier = Modifier.weight(1f), + onClick = { + onConfirm() + onDismiss() + }, + isCompact = true + ) + } + } + } + } +} + +@Composable +fun UndabangConfirmDialog( + title: String, + message: String, + modifier: Modifier = Modifier, + confirmText: String = "확인", + onConfirm: () -> Unit, + onDismiss: () -> Unit, + isCancelable: Boolean = true +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = isCancelable, + dismissOnClickOutside = isCancelable + ) + ) { + Surface( + modifier = modifier + .fillMaxWidth(0.85f), + shape = MaterialTheme.shapes.large, + color = ColorWhite300 + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.mediumBold, + color = ColorBlack, + textAlign = TextAlign.Center, + ) + + Text( + text = message, + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + textAlign = TextAlign.Center, + ) + + PrimaryButton( + text = confirmText, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + onClick = { + onConfirm() + onDismiss() + }, + isCompact = true + ) + } + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialogPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialogPreview.kt new file mode 100644 index 00000000..ba98a988 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialogPreview.kt @@ -0,0 +1,86 @@ +package com.project200.presentation.compose.components.feedback + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview +import com.project200.presentation.compose.theme.AppTheme + +@Preview(showBackground = true, widthDp = 400, heightDp = 600) +@Composable +fun AlertDialogPreview() { + AppTheme { + var showDialog by remember { mutableStateOf(true) } + + if (showDialog) { + UndabangAlertDialog( + title = "삭제 확인", + message = "정말로 이 항목을 삭제하시겠습니까?", + confirmText = "삭제", + cancelText = "취소", + onConfirm = { showDialog = false }, + onCancel = { showDialog = false }, + onDismiss = { showDialog = false } + ) + } + } +} + +@Preview(showBackground = true, widthDp = 400, heightDp = 600) +@Composable +fun AlertDialogConfirmOnlyPreview() { + AppTheme { + var showDialog by remember { mutableStateOf(true) } + + if (showDialog) { + UndabangAlertDialog( + title = "완료", + message = "피드가 성공적으로 업로드되었습니다!", + confirmText = "확인", + cancelText = "", + onConfirm = { showDialog = false }, + onDismiss = { showDialog = false }, + isCancelable = true + ) + } + } +} + +@Preview(showBackground = true, widthDp = 400, heightDp = 600) +@Composable +fun ConfirmDialogPreview() { + AppTheme { + var showDialog by remember { mutableStateOf(true) } + + if (showDialog) { + UndabangConfirmDialog( + title = "완료", + message = "변경사항이 저장되었습니다.", + confirmText = "확인", + onConfirm = { showDialog = false }, + onDismiss = { showDialog = false } + ) + } + } +} + +@Preview(showBackground = true, widthDp = 400, heightDp = 600) +@Composable +fun AlertDialogNotCancelablePreview() { + AppTheme { + var showDialog by remember { mutableStateOf(true) } + + if (showDialog) { + UndabangAlertDialog( + title = "중요 알림", + message = "네트워크 연결을 확인해주세요.", + confirmText = "확인", + onConfirm = { showDialog = false }, + onDismiss = { showDialog = false }, + isCancelable = false + ) + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt new file mode 100644 index 00000000..b160977f --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt @@ -0,0 +1,181 @@ +package com.project200.presentation.compose.components.input + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorErrorRed +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorGray300 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite100 +import com.project200.presentation.compose.theme.ColorWhite300 + +enum class TextFieldVariant { + FilledSmall, + FilledLarge, + Outlined +} + +@Composable +fun UndabangTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + hint: String = "", + variant: TextFieldVariant = TextFieldVariant.FilledSmall, + singleLine: Boolean = variant == TextFieldVariant.FilledSmall, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + keyboardType: KeyboardType = KeyboardType.Text, + keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = keyboardType), + maxLength: Int? = null, + enabled: Boolean = true, + readOnly: Boolean = false, + isError: Boolean = false, + supportingText: String = "", + onFocusChange: (Boolean) -> Unit = {}, + visualTransformation: VisualTransformation = VisualTransformation.None +) { + val interactionSource = remember { MutableInteractionSource() } + + val style = when (variant) { + TextFieldVariant.FilledSmall -> TextFieldStyle( + backgroundColor = ColorWhite100, + cornerRadius = 5.dp, + borderColor = null, + borderWidth = null, + padding = 12.dp, + minHeight = 44.dp, + hintColor = ColorGray200, + ) + TextFieldVariant.FilledLarge -> TextFieldStyle( + backgroundColor = ColorWhite100, + cornerRadius = 8.dp, + borderColor = null, + borderWidth = null, + padding = 16.dp, + minHeight = 0.dp, + hintColor = ColorGray200, + ) + TextFieldVariant.Outlined -> TextFieldStyle( + backgroundColor = ColorWhite100, + cornerRadius = 8.dp, + borderColor = if (isError) ColorErrorRed else ColorGray200, + borderWidth = 1.dp, + padding = 12.dp, + minHeight = 44.dp, + hintColor = ColorGray200, + ) + } + + Column(modifier = modifier) { + Box( + modifier = Modifier + .fillMaxWidth() + .then( + if (style.minHeight > 0.dp) Modifier.height(style.minHeight) else Modifier + ) + .background( + color = if (enabled) style.backgroundColor else ColorGray300, + shape = RoundedCornerShape(style.cornerRadius), + ) + .then( + if (style.borderColor != null && style.borderWidth != null) { + Modifier.border( + width = style.borderWidth, + color = style.borderColor, + shape = RoundedCornerShape(style.cornerRadius), + ) + } else { + Modifier + } + ) + .padding(style.padding), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = value, + onValueChange = { newValue -> + if (maxLength == null || newValue.length <= maxLength) { + onValueChange(newValue) + } + }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> onFocusChange(focusState.isFocused) } + .then( + if (style.minHeight > 0.dp) Modifier else Modifier.defaultMinSize(minHeight = 56.dp) + ), + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + keyboardOptions = keyboardOptions, + textStyle = LocalTextStyle.current.copy( + fontSize = 14.sp, + color = if (enabled) ColorBlack else ColorGray200 + ), + enabled = enabled && !readOnly, + readOnly = readOnly, + visualTransformation = visualTransformation, + interactionSource = interactionSource, + decorationBox = { innerTextField -> + Box(modifier = Modifier.fillMaxWidth()) { + if (value.isEmpty()) { + Text( + text = hint, + color = style.hintColor, + fontSize = 14.sp + ) + } + innerTextField() + } + } + ) + } + + if (supportingText.isNotEmpty()) { + Text( + text = supportingText, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, start = style.padding, end = style.padding), + color = if (isError) ColorErrorRed else ColorMain, + fontSize = 12.sp + ) + } + } +} + +private data class TextFieldStyle( + val backgroundColor: Color, + val cornerRadius: Dp, + val borderColor: Color?, + val borderWidth: Dp?, + val padding: Dp, + val minHeight: Dp, + val hintColor: Color, +) diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt new file mode 100644 index 00000000..9bb2ffd8 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt @@ -0,0 +1,175 @@ +package com.project200.presentation.compose.components.input + +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.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.theme.AppTheme + +@Preview(showBackground = true) +@Composable +fun TextFieldFilledSmallPreview() { + AppTheme { + var value by remember { mutableStateOf("") } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + UndabangTextField( + value = value, + onValueChange = { value = it }, + hint = "닉네임을 입력하세요", + variant = TextFieldVariant.FilledSmall, + singleLine = true + ) + Spacer(modifier = Modifier.height(12.dp)) + UndabangTextField( + value = value, + onValueChange = { value = it }, + hint = "닉네임을 입력하세요", + variant = TextFieldVariant.FilledSmall, + singleLine = true, + enabled = false + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun TextFieldFilledLargePreview() { + AppTheme { + var value by remember { mutableStateOf("") } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + UndabangTextField( + value = value, + onValueChange = { value = it }, + hint = "긴 내용을 입력하세요", + variant = TextFieldVariant.FilledLarge, + singleLine = false, + maxLines = 5 + ) + Spacer(modifier = Modifier.height(12.dp)) + UndabangTextField( + value = value, + onValueChange = { value = it }, + hint = "긴 내용을 입력하세요", + variant = TextFieldVariant.FilledLarge, + singleLine = false, + enabled = false + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun TextFieldOutlinedPreview() { + AppTheme { + var value by remember { mutableStateOf("") } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + UndabangTextField( + value = value, + onValueChange = { value = it }, + hint = "장소명을 입력하세요", + variant = TextFieldVariant.Outlined, + singleLine = true + ) + Spacer(modifier = Modifier.height(12.dp)) + UndabangTextField( + value = value, + onValueChange = { value = it }, + hint = "장소명을 입력하세요", + variant = TextFieldVariant.Outlined, + singleLine = true, + isError = true, + supportingText = "중복된 장소입니다" + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun TextFieldNumericPreview() { + AppTheme { + var minute by remember { mutableStateOf("10") } + var second by remember { mutableStateOf("30") } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + UndabangTextField( + value = minute, + onValueChange = { minute = it }, + hint = "분", + variant = TextFieldVariant.FilledSmall, + singleLine = true, + keyboardType = KeyboardType.Number, + maxLength = 2 + ) + Spacer(modifier = Modifier.height(12.dp)) + UndabangTextField( + value = second, + onValueChange = { second = it }, + hint = "초", + variant = TextFieldVariant.FilledSmall, + singleLine = true, + keyboardType = KeyboardType.Number, + maxLength = 2 + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun TextFieldWithSupportingTextPreview() { + AppTheme { + var value by remember { mutableStateOf("undabang2024") } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + UndabangTextField( + value = value, + onValueChange = { value = it }, + hint = "닉네임", + variant = TextFieldVariant.FilledSmall, + singleLine = true, + supportingText = "사용 가능한 닉네임입니다" + ) + Spacer(modifier = Modifier.height(16.dp)) + UndabangTextField( + value = value, + onValueChange = { value = it }, + hint = "닉네임", + variant = TextFieldVariant.FilledSmall, + singleLine = true, + isError = true, + supportingText = "이미 사용 중인 닉네임입니다" + ) + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt new file mode 100644 index 00000000..df68d4b5 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt @@ -0,0 +1,103 @@ +package com.project200.presentation.compose.components.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.mediumBold + +@Composable +fun UndabangTopBar( + title: String, + modifier: Modifier = Modifier, + onNavigationClick: (() -> Unit)? = null, + navigationIcon: ImageVector = Icons.AutoMirrored.Filled.ArrowBack, + navigationIconVisible: Boolean = true, + actionIcon1: ImageVector? = null, + actionIcon1Description: String = "", + onAction1Click: (() -> Unit)? = null, + actionIcon2: ImageVector? = null, + actionIcon2Description: String = "", + onAction2Click: (() -> Unit)? = null, + actions: @Composable () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(65.dp) + .background(color = MaterialTheme.colorScheme.primary) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (navigationIconVisible) { + IconButton( + onClick = { onNavigationClick?.invoke() }, + modifier = Modifier.weight(0.1f) + ) { + Icon( + imageVector = navigationIcon, + contentDescription = "뒤로가기", + tint = ColorWhite300 + ) + } + } else { + Box(modifier = Modifier.weight(0.1f)) + } + + Text( + text = title, + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + style = MaterialTheme.typography.mediumBold, + color = ColorWhite300, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Row( + modifier = Modifier.weight(0.25f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (actionIcon2 != null && onAction2Click != null) { + IconButton(onClick = onAction2Click) { + Icon( + imageVector = actionIcon2, + contentDescription = actionIcon2Description, + tint = ColorWhite300, + ) + } + } + + if (actionIcon1 != null && onAction1Click != null) { + IconButton(onClick = onAction1Click) { + Icon( + imageVector = actionIcon1, + contentDescription = actionIcon1Description, + tint = ColorWhite300, + ) + } + } + + actions() + } + } +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBarPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBarPreview.kt new file mode 100644 index 00000000..e5f83bdf --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBarPreview.kt @@ -0,0 +1,64 @@ +package com.project200.presentation.compose.components.layout + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Share +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.theme.AppTheme + +@Preview(showBackground = true) +@Composable +fun TopBarPreviewBackOnly() { + AppTheme { + UndabangTopBar( + title = "피드", + onNavigationClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun TopBarPreviewWithOneAction() { + AppTheme { + UndabangTopBar( + title = "피드 상세", + onNavigationClick = {}, + actionIcon1 = Icons.Default.MoreVert, + onAction1Click = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun TopBarPreviewWithTwoActions() { + AppTheme { + UndabangTopBar( + title = "채팅방", + onNavigationClick = {}, + actionIcon1 = Icons.Default.Share, + onAction1Click = {}, + actionIcon2 = Icons.Default.Delete, + onAction2Click = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun TopBarPreviewNoNavigation() { + AppTheme { + UndabangTopBar( + title = "설정", + navigationIconVisible = false, + onNavigationClick = {} + ) + } +} From a64c1aa8678e8b39b20fc8f52a2da1c4adf78cba Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 24 Apr 2026 14:56:46 +0900 Subject: [PATCH 04/99] =?UTF-8?q?refactor:=20Compose=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20API=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20XML=20=EB=B9=84=EC=A3=BC=EC=96=BC=20?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20#489?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파라미터 순서를 Compose 관례에 맞춤 (required → modifier → optional) - 내부 fillMaxWidth 제거해 레이아웃 결정권을 호출부로 이관 - Button 에 height 오버라이드 파라미터 추가 (Dialog 45dp 대응) - 존재 검증용 색상은 MaterialTheme.colorScheme 슬롯으로 치환 - UndabangTopBar 의 actionIcon1/2 파라미터를 RowScope 슬롯으로 단순화 - UndabangTopBar 배경/글자/제목 정렬을 기존 XML view_base_toolbar 비주얼로 복원 - UndabangAlertDialog 의 제목/메시지 스타일·섹션 간격·버튼 높이를 dialog_base_alert 비주얼로 복원 - UndabangConfirmDialog 제거 후 UndabangAlertDialog(onCancel = null) 로 통합 - UndabangAvatar 가 imageUrl null/blank 시 placeholder 로 자동 폴백 - UndabangCard 를 Avatar.kt 에서 Card.kt 로 분리 - TextField 의 keyboardType 파라미터 제거, keyboardOptions 만 유지 - TextField maxLength 초과 시 잘라서 반영 (IME 붙여넣기 UX) - TextField/TextButton 최소 높이 48dp 로 상향 (접근성) - TextField FilledSmall 코너 5dp → 8dp (bg_solid_corner 와 일치) - 공통 컴포넌트 각각에 용도·사용법 KDoc 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../compose/components/button/Button.kt | 61 +++++---- .../compose/components/display/Avatar.kt | 91 ++++--------- .../compose/components/display/Badge.kt | 35 +++-- .../compose/components/display/Card.kt | 36 +++++ .../components/display/DisplayPreview.kt | 8 +- .../components/feedback/AlertDialog.kt | 126 ++++++------------ .../components/feedback/AlertDialogPreview.kt | 24 +--- .../compose/components/input/TextField.kt | 50 ++++--- .../components/input/TextFieldPreview.kt | 5 +- .../compose/components/layout/TopBar.kt | 97 +++++++------- .../components/layout/TopBarPreview.kt | 44 ++++-- 11 files changed, 275 insertions(+), 302 deletions(-) create mode 100644 presentation/src/main/java/com/project200/presentation/compose/components/display/Card.kt diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt b/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt index 49033a24..d739bccc 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt @@ -1,7 +1,6 @@ package com.project200.presentation.compose.components.button import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -10,36 +9,41 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.project200.presentation.compose.theme.ColorGray200 import com.project200.presentation.compose.theme.ColorGray300 -import com.project200.presentation.compose.theme.ColorMain import com.project200.presentation.compose.theme.ColorWhite100 import com.project200.presentation.compose.theme.ColorWhite300 import com.project200.presentation.compose.theme.subtext14 +/** + * 주 액션 (완료·확인·제출) 에 쓰는 메인 버튼. + * + * 브랜드 primary 색 배경 + onPrimary 글자. 기본 높이 55dp, [isCompact] = true 면 36dp. + * 화면 폭을 꽉 채우려면 호출부에서 `modifier = Modifier.fillMaxWidth()` 를 전달한다. + */ @Composable fun PrimaryButton( text: String, - modifier: Modifier = Modifier, onClick: () -> Unit, + modifier: Modifier = Modifier, enabled: Boolean = true, - isCompact: Boolean = false + isCompact: Boolean = false, + height: Dp = if (isCompact) 36.dp else 55.dp, ) { Button( onClick = onClick, - modifier = modifier - .fillMaxWidth() - .height(if (isCompact) 36.dp else 55.dp), + modifier = modifier.height(height), colors = ButtonDefaults.buttonColors( - containerColor = ColorMain, - contentColor = ColorWhite300, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, disabledContainerColor = ColorGray200, disabledContentColor = ColorWhite300, ), shape = MaterialTheme.shapes.large, enabled = enabled, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = if (isCompact) 8.dp else 12.dp) + contentPadding = PaddingValues(horizontal = 16.dp, vertical = if (isCompact) 8.dp else 12.dp), ) { Text( text = text, @@ -48,19 +52,24 @@ fun PrimaryButton( } } +/** + * 보조 액션 (취소·뒤로) 에 쓰는 버튼. Primary 와 짝으로 배치되는 경우가 많다. + * + * 회색 배경 + 흰 글자. 크기·shape 는 [PrimaryButton] 과 동일하게 유지해 같은 줄에 놓아도 어울린다. + * 색 조합은 XML 시절 디자인을 그대로 따르고 있어 ColorScheme slot 매핑 대신 raw 색을 쓴다. + */ @Composable fun SecondaryButton( text: String, - modifier: Modifier = Modifier, onClick: () -> Unit, + modifier: Modifier = Modifier, enabled: Boolean = true, - isCompact: Boolean = false + isCompact: Boolean = false, + height: Dp = if (isCompact) 36.dp else 55.dp, ) { Button( onClick = onClick, - modifier = modifier - .fillMaxWidth() - .height(if (isCompact) 36.dp else 55.dp), + modifier = modifier.height(height), colors = ButtonDefaults.buttonColors( containerColor = ColorGray300, contentColor = ColorWhite300, @@ -69,7 +78,7 @@ fun SecondaryButton( ), shape = MaterialTheme.shapes.large, enabled = enabled, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = if (isCompact) 8.dp else 12.dp) + contentPadding = PaddingValues(horizontal = 16.dp, vertical = if (isCompact) 8.dp else 12.dp), ) { Text( text = text, @@ -78,27 +87,31 @@ fun SecondaryButton( } } +/** + * 텍스트성 보조 액션 (중복 확인·재전송·더보기 등) 에 쓰는 가벼운 버튼. + * + * 연회색 배경 + primary 글자로, 다른 입력 요소 옆에 붙여 써도 시선을 덜 뺏는다. + * 기본은 compact (32dp) — 인라인 입력 필드 옆에 붙이는 용도로 최적화되어 있다. + */ @Composable fun TextButton( text: String, - modifier: Modifier = Modifier, onClick: () -> Unit, + modifier: Modifier = Modifier, enabled: Boolean = true, - isCompact: Boolean = true + isCompact: Boolean = true, ) { OutlinedButton( onClick = onClick, - modifier = modifier - .fillMaxWidth() - .height(if (isCompact) 32.dp else 44.dp), + modifier = modifier.height(if (isCompact) 32.dp else 48.dp), colors = ButtonDefaults.outlinedButtonColors( containerColor = ColorWhite100, - contentColor = ColorMain, - disabledContentColor = ColorGray200 + contentColor = MaterialTheme.colorScheme.primary, + disabledContentColor = ColorGray200, ), shape = MaterialTheme.shapes.small, enabled = enabled, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = if (isCompact) 4.dp else 8.dp) + contentPadding = PaddingValues(horizontal = 12.dp, vertical = if (isCompact) 4.dp else 8.dp), ) { Text( text = text, diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt index b09812b4..225f27ee 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt @@ -7,10 +7,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,21 +20,28 @@ import coil.compose.AsyncImage import com.project200.presentation.compose.theme.ColorGray200 import com.project200.presentation.compose.theme.ColorGray300 import com.project200.presentation.compose.theme.ColorMain -import com.project200.presentation.compose.theme.ColorWhite300 +/** + * 원형 프로필 아바타. 이미지 로드 실패·URL 부재를 내부에서 폴백 처리한다. + * + * [imageUrl] 이 null 또는 blank 면 Person 아이콘 placeholder 를 보여주므로, + * 호출부가 "프로필 설정 여부" 로 분기하지 않아도 된다. 크기·테두리 색은 목록·상세 등 상황에 맞게 조절. + */ @Composable fun UndabangAvatar( - imageUrl: String, + imageUrl: String?, modifier: Modifier = Modifier, size: Dp = 48.dp, borderColor: Color = ColorMain, borderWidth: Dp = 2.dp, + placeholderBackground: Color = ColorGray300, contentDescription: String = "프로필 이미지", ) { Box( modifier = modifier .size(size) .clip(CircleShape) + .background(color = placeholderBackground, shape = CircleShape) .border( width = borderWidth, color = borderColor, @@ -45,65 +49,22 @@ fun UndabangAvatar( ), contentAlignment = Alignment.Center, ) { - AsyncImage( - model = imageUrl, - contentDescription = contentDescription, - modifier = Modifier - .size(size - borderWidth * 2) - .clip(CircleShape), - contentScale = ContentScale.Crop, - placeholder = null, - error = null, - fallback = null, - ) - } -} - -@Composable -fun UndabangPlaceholderAvatar( - modifier: Modifier = Modifier, - size: Dp = 48.dp, - backgroundColor: Color = ColorGray300, - borderColor: Color = ColorMain, - borderWidth: Dp = 2.dp, -) { - Box( - modifier = modifier - .size(size) - .clip(CircleShape) - .background(color = backgroundColor, shape = CircleShape) - .border( - width = borderWidth, - color = borderColor, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = "기본 프로필", - modifier = Modifier.size(size * 0.5f), - tint = ColorGray200, - ) - } -} - -@Composable -fun UndabangCard( - modifier: Modifier = Modifier, - backgroundColor: Color = ColorWhite300, - content: @Composable () -> Unit, -) { - Card( - modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = backgroundColor, - ), - shape = MaterialTheme.shapes.medium, - elevation = CardDefaults.cardElevation( - defaultElevation = 4.dp, - ), - ) { - content() + if (imageUrl.isNullOrBlank()) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = contentDescription, + modifier = Modifier.size(size * 0.5f), + tint = ColorGray200, + ) + } else { + AsyncImage( + model = imageUrl, + contentDescription = contentDescription, + modifier = Modifier + .size(size - borderWidth * 2) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } } } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt index 03569fdb..da3261d3 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt @@ -4,56 +4,65 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.project200.presentation.compose.theme.ColorErrorRed -import com.project200.presentation.compose.theme.ColorMain -import com.project200.presentation.compose.theme.ColorWhite300 +/** + * 숫자 표시용 원형 뱃지. 탭바 아이콘·프로필 썸네일 위에 올려 알림 개수 등을 노출한다. + * + * 100 이상이면 "99+" 로 표기해 폭이 폭발하지 않는다. 기본 색은 error (빨강) — 중요 알림용. + */ @Composable fun UndabangBadge( count: Int, modifier: Modifier = Modifier, - backgroundColor: Color = ColorErrorRed, - contentColor: Color = ColorWhite300, - size: androidx.compose.ui.unit.Dp = 24.dp + backgroundColor: Color = MaterialTheme.colorScheme.error, + contentColor: Color = MaterialTheme.colorScheme.onError, + size: Dp = 24.dp, ) { Box( modifier = modifier .size(size) .background( color = backgroundColor, - shape = CircleShape + shape = CircleShape, ), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Text( text = if (count > 99) "99+" else count.toString(), fontSize = 10.sp, fontWeight = FontWeight.Bold, - color = contentColor + color = contentColor, ) } } +/** + * 숫자 없는 작은 상태 점. "읽지 않음" 표시처럼 존재 여부만 알리고 싶을 때 쓴다. + * + * [UndabangBadge] 와 달리 카운트를 보여주지 않아 화면 지저분함을 줄인다. + */ @Composable fun UndabangDot( modifier: Modifier = Modifier, - color: Color = ColorErrorRed, - size: androidx.compose.ui.unit.Dp = 12.dp + color: Color = MaterialTheme.colorScheme.error, + size: Dp = 12.dp, ) { Box( modifier = modifier .size(size) .background( color = color, - shape = CircleShape - ) + shape = CircleShape, + ), ) } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/Card.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/Card.kt new file mode 100644 index 00000000..2c1d258c --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/Card.kt @@ -0,0 +1,36 @@ +package com.project200.presentation.compose.components.display + +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.theme.ColorWhite300 + +/** + * 그림자 있는 카드 컨테이너. 리스트 아이템·섹션 박스 등 시각적으로 묶어야 하는 영역에 쓴다. + * + * Material3 `Card` 래퍼로, 앱 기본 shape (medium, 8dp 라운드) 과 4dp elevation 을 기본값으로 준다. + * 내부 여백은 호출부가 직접 관리 (카드는 레이아웃 껍데기, 안은 호출부 자유). + */ +@Composable +fun UndabangCard( + modifier: Modifier = Modifier, + backgroundColor: Color = ColorWhite300, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = backgroundColor, + ), + shape = MaterialTheme.shapes.medium, + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + ), + ) { + content() + } +} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt index 521b3186..1f8e86b4 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt @@ -41,9 +41,9 @@ fun AvatarPreview() { horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - UndabangPlaceholderAvatar(size = 32.dp) - UndabangPlaceholderAvatar(size = 48.dp) - UndabangPlaceholderAvatar(size = 64.dp) + UndabangAvatar(imageUrl = null, size = 32.dp) + UndabangAvatar(imageUrl = null, size = 48.dp) + UndabangAvatar(imageUrl = null, size = 64.dp) } Text("다양한 크기", fontWeight = FontWeight.Bold, fontSize = 14.sp) @@ -189,7 +189,7 @@ fun DisplayComponentsGroupPreview() { horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - UndabangPlaceholderAvatar(size = 48.dp) + UndabangAvatar(imageUrl = null, size = 48.dp) Column( modifier = Modifier.weight(1f) diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt index 5cf048b8..f846b64e 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt @@ -19,144 +19,100 @@ import com.project200.presentation.compose.components.button.PrimaryButton import com.project200.presentation.compose.components.button.SecondaryButton import com.project200.presentation.compose.theme.ColorBlack import com.project200.presentation.compose.theme.ColorWhite300 -import com.project200.presentation.compose.theme.mediumBold -import com.project200.presentation.compose.theme.subtext14 +import com.project200.presentation.compose.theme.contentBold +/** XML `dialog_base_alert` 기준 다이얼로그 버튼 높이 (XML 45dp). */ +private val DialogButtonHeight = 45.dp + +/** + * 제목 + 메시지 + 액션 버튼으로 구성된 공통 확인 다이얼로그. + * + * 기존 XML `dialog_base_alert` 비주얼을 그대로 이식 — 제목/메시지 모두 content_bold(15sp bold), + * 섹션 간 간격 32dp, 버튼 높이 45dp. + * [onCancel] 을 주면 취소/확인 두 버튼이 나란히 표시되고, null 이면 확인 버튼 하나만 전체 폭으로 표시된다. + * [isCancelable] = false 면 뒤로가기·바깥 탭으로 닫히지 않아 네트워크 오류 같은 블로킹 안내에 쓴다. + */ @Composable fun UndabangAlertDialog( title: String, message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, modifier: Modifier = Modifier, confirmText: String = "확인", cancelText: String = "취소", - onConfirm: () -> Unit, onCancel: (() -> Unit)? = null, - onDismiss: () -> Unit, - isCancelable: Boolean = true + isCancelable: Boolean = true, ) { Dialog( onDismissRequest = onDismiss, properties = DialogProperties( dismissOnBackPress = isCancelable, - dismissOnClickOutside = isCancelable - ) + dismissOnClickOutside = isCancelable, + ), ) { Surface( - modifier = modifier - .fillMaxWidth(0.85f), + modifier = modifier.fillMaxWidth(0.85f), shape = MaterialTheme.shapes.large, - color = ColorWhite300 + color = ColorWhite300, ) { Column( modifier = Modifier .fillMaxWidth() .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(32.dp), ) { Text( text = title, - style = MaterialTheme.typography.mediumBold, + style = MaterialTheme.typography.contentBold, color = ColorBlack, textAlign = TextAlign.Center, ) Text( text = message, - style = MaterialTheme.typography.subtext14, + style = MaterialTheme.typography.contentBold, color = ColorBlack, textAlign = TextAlign.Center, ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (onCancel != null) { + if (onCancel != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { SecondaryButton( text = cancelText, - modifier = Modifier.weight(1f), onClick = { onCancel() onDismiss() }, - isCompact = true + modifier = Modifier.weight(1f), + height = DialogButtonHeight, + ) + PrimaryButton( + text = confirmText, + onClick = { + onConfirm() + onDismiss() + }, + modifier = Modifier.weight(1f), + height = DialogButtonHeight, ) } - + } else { PrimaryButton( text = confirmText, - modifier = Modifier.weight(1f), onClick = { onConfirm() onDismiss() }, - isCompact = true + modifier = Modifier.fillMaxWidth(), + height = DialogButtonHeight, ) } } } } } - -@Composable -fun UndabangConfirmDialog( - title: String, - message: String, - modifier: Modifier = Modifier, - confirmText: String = "확인", - onConfirm: () -> Unit, - onDismiss: () -> Unit, - isCancelable: Boolean = true -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties( - dismissOnBackPress = isCancelable, - dismissOnClickOutside = isCancelable - ) - ) { - Surface( - modifier = modifier - .fillMaxWidth(0.85f), - shape = MaterialTheme.shapes.large, - color = ColorWhite300 - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.mediumBold, - color = ColorBlack, - textAlign = TextAlign.Center, - ) - - Text( - text = message, - style = MaterialTheme.typography.subtext14, - color = ColorBlack, - textAlign = TextAlign.Center, - ) - - PrimaryButton( - text = confirmText, - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - onClick = { - onConfirm() - onDismiss() - }, - isCompact = true - ) - } - } - } -} diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialogPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialogPreview.kt index ba98a988..26cc54d1 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialogPreview.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialogPreview.kt @@ -22,7 +22,7 @@ fun AlertDialogPreview() { cancelText = "취소", onConfirm = { showDialog = false }, onCancel = { showDialog = false }, - onDismiss = { showDialog = false } + onDismiss = { showDialog = false }, ) } } @@ -39,28 +39,8 @@ fun AlertDialogConfirmOnlyPreview() { title = "완료", message = "피드가 성공적으로 업로드되었습니다!", confirmText = "확인", - cancelText = "", onConfirm = { showDialog = false }, onDismiss = { showDialog = false }, - isCancelable = true - ) - } - } -} - -@Preview(showBackground = true, widthDp = 400, heightDp = 600) -@Composable -fun ConfirmDialogPreview() { - AppTheme { - var showDialog by remember { mutableStateOf(true) } - - if (showDialog) { - UndabangConfirmDialog( - title = "완료", - message = "변경사항이 저장되었습니다.", - confirmText = "확인", - onConfirm = { showDialog = false }, - onDismiss = { showDialog = false } ) } } @@ -79,7 +59,7 @@ fun AlertDialogNotCancelablePreview() { confirmText = "확인", onConfirm = { showDialog = false }, onDismiss = { showDialog = false }, - isCancelable = false + isCancelable = false, ) } } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt index b160977f..23a714d8 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -21,7 +20,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -32,14 +30,26 @@ import com.project200.presentation.compose.theme.ColorGray200 import com.project200.presentation.compose.theme.ColorGray300 import com.project200.presentation.compose.theme.ColorMain import com.project200.presentation.compose.theme.ColorWhite100 -import com.project200.presentation.compose.theme.ColorWhite300 +/** + * [UndabangTextField] 의 시각 변형. + * + * - [FilledSmall]: 한 줄 입력 (닉네임, 검색어 등). 연회색 배경, 48dp 고정 높이 + * - [FilledLarge]: 여러 줄 입력 (본문, 소개글 등). 연회색 배경, 높이 제한 없음 + * - [Outlined]: 에러/검증 상태를 테두리로 강조하는 입력 (장소명, 이메일 등) + */ enum class TextFieldVariant { FilledSmall, FilledLarge, - Outlined + Outlined, } +/** + * 기본 텍스트 입력 필드. 상태 hoisting 패턴으로 [value] / [onValueChange] 를 호출부가 관리한다. + * + * [variant] 로 한 줄/여러 줄/아웃라인 중 선택. [maxLength] 를 주면 붙여넣기로 초과 입력 시 잘라서 반영한다. + * 에러 안내는 [isError] + [supportingText] 로, 도움말 텍스트는 [supportingText] (에러 아닐 때) 로 표시한다. + */ @Composable fun UndabangTextField( value: String, @@ -50,26 +60,25 @@ fun UndabangTextField( singleLine: Boolean = variant == TextFieldVariant.FilledSmall, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, minLines: Int = 1, - keyboardType: KeyboardType = KeyboardType.Text, - keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = keyboardType), + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, maxLength: Int? = null, enabled: Boolean = true, readOnly: Boolean = false, isError: Boolean = false, supportingText: String = "", onFocusChange: (Boolean) -> Unit = {}, - visualTransformation: VisualTransformation = VisualTransformation.None + visualTransformation: VisualTransformation = VisualTransformation.None, ) { val interactionSource = remember { MutableInteractionSource() } val style = when (variant) { TextFieldVariant.FilledSmall -> TextFieldStyle( backgroundColor = ColorWhite100, - cornerRadius = 5.dp, + cornerRadius = 8.dp, borderColor = null, borderWidth = null, padding = 12.dp, - minHeight = 44.dp, + minHeight = 48.dp, hintColor = ColorGray200, ) TextFieldVariant.FilledLarge -> TextFieldStyle( @@ -87,7 +96,7 @@ fun UndabangTextField( borderColor = if (isError) ColorErrorRed else ColorGray200, borderWidth = 1.dp, padding = 12.dp, - minHeight = 44.dp, + minHeight = 48.dp, hintColor = ColorGray200, ) } @@ -97,7 +106,7 @@ fun UndabangTextField( modifier = Modifier .fillMaxWidth() .then( - if (style.minHeight > 0.dp) Modifier.height(style.minHeight) else Modifier + if (style.minHeight > 0.dp) Modifier.height(style.minHeight) else Modifier, ) .background( color = if (enabled) style.backgroundColor else ColorGray300, @@ -112,23 +121,22 @@ fun UndabangTextField( ) } else { Modifier - } + }, ) .padding(style.padding), - contentAlignment = Alignment.CenterStart + contentAlignment = Alignment.CenterStart, ) { BasicTextField( value = value, onValueChange = { newValue -> - if (maxLength == null || newValue.length <= maxLength) { - onValueChange(newValue) - } + val next = if (maxLength != null) newValue.take(maxLength) else newValue + if (next != value) onValueChange(next) }, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> onFocusChange(focusState.isFocused) } .then( - if (style.minHeight > 0.dp) Modifier else Modifier.defaultMinSize(minHeight = 56.dp) + if (style.minHeight > 0.dp) Modifier else Modifier.defaultMinSize(minHeight = 56.dp), ), singleLine = singleLine, maxLines = maxLines, @@ -136,7 +144,7 @@ fun UndabangTextField( keyboardOptions = keyboardOptions, textStyle = LocalTextStyle.current.copy( fontSize = 14.sp, - color = if (enabled) ColorBlack else ColorGray200 + color = if (enabled) ColorBlack else ColorGray200, ), enabled = enabled && !readOnly, readOnly = readOnly, @@ -148,12 +156,12 @@ fun UndabangTextField( Text( text = hint, color = style.hintColor, - fontSize = 14.sp + fontSize = 14.sp, ) } innerTextField() } - } + }, ) } @@ -164,7 +172,7 @@ fun UndabangTextField( .fillMaxWidth() .padding(top = 4.dp, start = style.padding, end = style.padding), color = if (isError) ColorErrorRed else ColorMain, - fontSize = 12.sp + fontSize = 12.sp, ) } } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt index 9bb2ffd8..2c81a4a6 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt @@ -5,6 +5,7 @@ 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.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -125,7 +126,7 @@ fun TextFieldNumericPreview() { hint = "분", variant = TextFieldVariant.FilledSmall, singleLine = true, - keyboardType = KeyboardType.Number, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), maxLength = 2 ) Spacer(modifier = Modifier.height(12.dp)) @@ -135,7 +136,7 @@ fun TextFieldNumericPreview() { hint = "초", variant = TextFieldVariant.FilledSmall, singleLine = true, - keyboardType = KeyboardType.Number, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), maxLength = 2 ) } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt index df68d4b5..e9bb005e 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt @@ -4,9 +4,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.layout.wrapContentWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon @@ -17,11 +21,20 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.theme.ColorBlack import com.project200.presentation.compose.theme.ColorWhite300 -import com.project200.presentation.compose.theme.mediumBold +import com.project200.presentation.compose.theme.contentBold +/** + * 화면 상단 앱바. 왼쪽 네비게이션 아이콘, 가운데 제목, 오른쪽 액션 슬롯 구조. + * + * 흰색 배경 + 검정 글자 + 중앙 정렬 제목 — 기존 XML `view_base_toolbar` 디자인을 그대로 옮긴 것. + * 액션 버튼은 [actions] 슬롯에 [IconButton] 등을 직접 넣어 원하는 개수만큼 배치한다. + * 홈/루트 화면처럼 뒤로가기가 필요 없으면 [navigationIconVisible] = false 로 자리만 남기고 숨긴다. + */ @Composable fun UndabangTopBar( title: String, @@ -29,75 +42,55 @@ fun UndabangTopBar( onNavigationClick: (() -> Unit)? = null, navigationIcon: ImageVector = Icons.AutoMirrored.Filled.ArrowBack, navigationIconVisible: Boolean = true, - actionIcon1: ImageVector? = null, - actionIcon1Description: String = "", - onAction1Click: (() -> Unit)? = null, - actionIcon2: ImageVector? = null, - actionIcon2Description: String = "", - onAction2Click: (() -> Unit)? = null, - actions: @Composable () -> Unit = {} + actions: @Composable RowScope.() -> Unit = {}, ) { - Row( + Box( modifier = modifier .fillMaxWidth() .height(65.dp) - .background(color = MaterialTheme.colorScheme.primary) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .background(color = ColorWhite300), ) { - if (navigationIconVisible) { - IconButton( - onClick = { onNavigationClick?.invoke() }, - modifier = Modifier.weight(0.1f) - ) { - Icon( - imageVector = navigationIcon, - contentDescription = "뒤로가기", - tint = ColorWhite300 - ) - } - } else { - Box(modifier = Modifier.weight(0.1f)) - } - Text( text = title, modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp), - style = MaterialTheme.typography.mediumBold, - color = ColorWhite300, + .align(Alignment.Center) + .padding(horizontal = 64.dp), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) Row( - modifier = Modifier.weight(0.25f), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - if (actionIcon2 != null && onAction2Click != null) { - IconButton(onClick = onAction2Click) { - Icon( - imageVector = actionIcon2, - contentDescription = actionIcon2Description, - tint = ColorWhite300, - ) - } - } - - if (actionIcon1 != null && onAction1Click != null) { - IconButton(onClick = onAction1Click) { + if (navigationIconVisible) { + IconButton( + onClick = { onNavigationClick?.invoke() }, + modifier = Modifier.size(48.dp), + ) { Icon( - imageVector = actionIcon1, - contentDescription = actionIcon1Description, - tint = ColorWhite300, + imageVector = navigationIcon, + contentDescription = "뒤로가기", + tint = ColorBlack, ) } + } else { + Box(modifier = Modifier.size(48.dp)) } - actions() + Row( + modifier = Modifier + .weight(1f) + .wrapContentWidth(align = Alignment.End), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) } } } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBarPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBarPreview.kt index e5f83bdf..fb61a475 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBarPreview.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBarPreview.kt @@ -1,15 +1,14 @@ package com.project200.presentation.compose.components.layout -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.project200.presentation.compose.theme.AppTheme @Preview(showBackground = true) @@ -18,7 +17,7 @@ fun TopBarPreviewBackOnly() { AppTheme { UndabangTopBar( title = "피드", - onNavigationClick = {} + onNavigationClick = {}, ) } } @@ -30,9 +29,15 @@ fun TopBarPreviewWithOneAction() { UndabangTopBar( title = "피드 상세", onNavigationClick = {}, - actionIcon1 = Icons.Default.MoreVert, - onAction1Click = {} - ) + ) { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "더보기", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } } } @@ -43,11 +48,22 @@ fun TopBarPreviewWithTwoActions() { UndabangTopBar( title = "채팅방", onNavigationClick = {}, - actionIcon1 = Icons.Default.Share, - onAction1Click = {}, - actionIcon2 = Icons.Default.Delete, - onAction2Click = {} - ) + ) { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "공유", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "삭제", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } } } @@ -58,7 +74,7 @@ fun TopBarPreviewNoNavigation() { UndabangTopBar( title = "설정", navigationIconVisible = false, - onNavigationClick = {} + onNavigationClick = {}, ) } } From 7533b6bb0a82d429d4435b09344ea7c8f0e0a6f7 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 24 Apr 2026 15:26:52 +0900 Subject: [PATCH 05/99] =?UTF-8?q?docs:=20Compose=20=E2=86=94=20XML=20?= =?UTF-8?q?=EB=B3=91=ED=96=89=20=EC=9A=B4=EC=98=81=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20#489?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 두 방향의 interop (ComposeView / AndroidView) 사용법 - Fragment 전환 전략 및 권장 순서 - 테마 경계·RecyclerView·Dialog·Insets 등 실전 함정 정리 - applyAppTheme 확장 함수 규약 제안 - 버전 호환 체크리스트 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/compose-xml-coexistence-guide.md | 182 ++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/compose-xml-coexistence-guide.md diff --git a/docs/compose-xml-coexistence-guide.md b/docs/compose-xml-coexistence-guide.md new file mode 100644 index 00000000..0fa18a4a --- /dev/null +++ b/docs/compose-xml-coexistence-guide.md @@ -0,0 +1,182 @@ +# Compose ↔ XML 병행 운영 가이드 + +기존 Fragment + ViewBinding + XML 구조를 Jetpack Compose 로 점진 마이그레이션할 때의 규약과 함정 정리. + +- 현재 프로젝트는 두 체계가 **한동안 공존**한다. +- 핵심 원칙: **한 레이어만 교체하고 나머지는 재사용**한다. Big bang 금지. + +--- + +## 1. 두 방향의 Interop + +### 방향 A — XML 안에 Compose 심기 (`ComposeView`) + +기존 XML 레이아웃의 일부만 Compose 로 교체할 때 쓴다. 예를 들어 기존 Fragment 의 상단 헤더만 `UndabangTopBar` 로 바꾸기. + +```xml + + + + + +``` + +```kotlin +override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.topBarCompose.setContent { + AppTheme { + UndabangTopBar( + title = "피드", + onNavigationClick = { findNavController().popBackStack() }, + ) + } + } +} +``` + +**반드시 지킬 것:** +- `AppTheme { ... }` 로 감싸기 — XML 테마는 ComposeView 로 상속되지 않는다. 빠뜨리면 `MaterialTheme.colorScheme.primary` 가 기본 보라색으로 나오고 "색이 왜 이상하지" 로 이어진다. +- **`ViewCompositionStrategy` 설정** — 기본값은 `DisposeOnDetachedFromWindow` 라 RecyclerView item 같이 반복 attach/detach 되는 곳에서 매번 컴포지션이 재생성된다. `DisposeOnViewTreeLifecycleDestroyed` 로 프래그먼트/뷰 라이프사이클에 맞추는 게 기본. + +### 방향 B — Compose 안에 XML 심기 (`AndroidView`) + +새로 만든 Compose 화면에서 이미 XML 로 구현된 컴포넌트 (KakaoMap, 복잡한 커스텀 뷰, 기존 차트 라이브러리) 를 재사용할 때. + +```kotlin +@Composable +fun MapScreen() { + AndroidView( + factory = { context -> MapView(context).apply { /* 세팅 */ } }, + update = { view -> /* 상태 변화 반영 */ }, + ) +} +``` + +`factory` 는 한 번만, `update` 는 recomposition 마다 호출된다. 상태 바인딩은 `update` 에서. + +--- + +## 2. Fragment 전환 전략 (권장 순서) + +### Phase 1 — Leaf 컴포넌트부터 교체 +`PrimaryButton`, `UndabangTextField` 같은 **재사용 UI 를 기존 XML 화면 안에서 ComposeView 로 부분 교체**. 리스크 낮고, 공통 컴포넌트의 실전 검증에도 좋다. + +### Phase 2 — 화면 전체를 Compose 로 +특정 Fragment 의 `onCreateView` 에서 XML 대신 `ComposeView` 반환: + +```kotlin +override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, +): View = ComposeView(requireContext()).apply { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, + ) + setContent { + AppTheme { + MyScreen(viewModel = hiltViewModel()) + } + } +} +``` + +Navigation Graph (XML) / ViewModel / Hilt / Navigation args 는 그대로 재사용. **Fragment 껍데기만 남기고 내용물만 Compose 로** 채우는 방식. 설정·약관·공지처럼 상태 간섭이 적은 화면부터 시작. + +### Phase 3 — Navigation-Compose 로 전환 (선택) +모든 Fragment 가 Compose 로 바뀐 뒤 고려. Compose 화면 비율이 70%+ 되기 전까지는 건드리지 않는 편이 안전. + +--- + +## 3. 실전 함정 + +### 테마 경계 +ComposeView 는 XML `AppTheme` 을 모른다. **ComposeView 안쪽에서 `AppTheme { }` 다시 감싸는 걸 빠뜨리지 말 것.** 이걸 잊으면 위에서 말한 보라색 지옥. + +프로젝트 관례로 **`applyAppTheme` 같은 확장 함수를 만들어 강제**하는 편이 좋다: + +```kotlin +fun ComposeView.applyAppTheme( + content: @Composable () -> Unit, +) { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, + ) + setContent { + AppTheme { content() } + } +} +``` + +이후 모든 ComposeView 진입점에서 `composeView.applyAppTheme { ... }` 만 호출하도록 규약화. + +### RecyclerView item 안에 ComposeView +기본 전략 `DisposeOnDetachedFromWindow` 는 재활용하며 매번 컴포지션이 폐기·재생성되어 성능이 나쁘다. **`DisposeOnViewTreeLifecycleDestroyed` 또는 `DisposeOnLifecycleDestroyed(viewLifecycleOwner)` 로** 프래그먼트 라이프사이클에 맞출 것. + +가능하면 item 레벨의 Compose 진입은 피하고, 리스트 자체를 Compose 로 가져갈 시점을 노리는 게 낫다. + +### Dialog / BottomSheet +Compose `Dialog` 는 안드로이드 Dialog 위에 별도 Compose 윈도우를 띄운다. 기존 `DialogFragment` 와 혼용 시 dismiss/backpress/스택 관리가 꼬이기 쉽다. 새 다이얼로그는 Compose 쪽 (`UndabangAlertDialog`) 으로 통일, 기존 DialogFragment 는 화면째 Compose 로 넘어올 때 같이 정리. + +### ViewModel 공유 +Fragment 의 `viewModels()` · `hiltViewModel()` 둘 다 ComposeView 안에서 그대로 동작한다. Flow 는 `collectAsStateWithLifecycle()`, LiveData 는 `observeAsState()` 로 관찰: + +```kotlin +val uiState by viewModel.uiState.collectAsStateWithLifecycle() +``` + +굳이 LiveData → StateFlow 전환을 먼저 하지 않아도 된다. + +### Navigation Args +XML Navigation safe args 는 Fragment 가 받고, 그대로 Compose 에 전달. `ViewModel` 이 `SavedStateHandle` 로 받는 패턴이 가장 깔끔. + +### Insets / SystemBar +XML 쪽은 `fitsSystemWindows`, Compose 쪽은 `Modifier.statusBarsPadding()` 계열. 혼용 시 패딩이 중복되기 쉽다. 화면 전체를 Compose 로 바꿀 때는 XML 쪽 `fitsSystemWindows` 를 빼고 Compose modifier 로 일원화. + +### Transition / Animation +XML 의 `android:animateLayoutChanges`, `Transition` API 는 ComposeView 경계를 넘지 못한다. 전환 애니메이션이 중요한 화면은 한 체계로 통일해서 만든다. + +### Preview vs Runtime 차이 +Preview 에서 색이 잘 보이는데 실제 실행에서 다르다면 거의 항상 **ComposeView 안 AppTheme 감싸기 누락**. + +--- + +## 4. 권장 마이그레이션 순서 (이 프로젝트 기준) + +1. 공통 컴포넌트 확정 (`PrimaryButton`, `UndabangTextField`, `UndabangTopBar`, `UndabangAlertDialog`, `UndabangAvatar`, `UndabangBadge`, `UndabangCard` 등) +2. **간단한 화면 하나** (설정·약관·공지 등 상태 간섭 적은 화면) 를 Fragment + ComposeView 로 전체 전환해 감 익히기 +3. `ComposeView.applyAppTheme` 확장 함수를 공통 모듈에 추가하고 규약화 +4. 자주 쓰는 일회성 UI (AlertDialog, BottomSheetDialog) 부터 Compose 로 일괄 교체 +5. 목록 화면 (RecyclerView) 은 마지막에. `LazyColumn` 으로 전환할 때는 스크롤 복원·paging 등 부수 작업이 따라오니 독립된 태스크로 다룬다 +6. Compose 화면 비율이 70%+ 되면 Navigation-Compose 전환 검토 + +--- + +## 5. 버전 · 호환 체크리스트 + +| 항목 | 현재 값 | 비고 | +|---|---|---| +| Kotlin | 1.9.23 | Compose Compiler 1.5.13 과 짝 | +| Compose Compiler | 1.5.13 | `kotlinCompilerExtensionVersion` 으로 주입 | +| Compose BOM | 2024.02.02 | BOM 으로 Compose 라이브러리 버전 고정 | +| Activity | 1.9.0 | `ComponentActivity.setContent` 지원 | +| Fragment | 1.7.1 | `ComposeView` 안정 지원 | + +**주의:** +- Kotlin 2.x 로 올릴 땐 `compose-compiler-gradle-plugin` 방식으로 전환해야 한다 (`kotlinCompilerExtensionVersion` 방식 폐지). 당장 업그레이드 계획은 없으나 라이브러리 업데이트 시 함께 확인. + +--- + +## 6. 체크리스트 + +새 ComposeView 진입점을 추가할 때: + +- [ ] `AppTheme { }` 으로 내용을 감쌌는가 (또는 `applyAppTheme` 확장을 사용했는가) +- [ ] `setViewCompositionStrategy` 를 `DisposeOnViewTreeLifecycleDestroyed` 로 설정했는가 (특히 RecyclerView item, Fragment 내부일 때) +- [ ] ViewModel 상태를 `collectAsStateWithLifecycle` / `observeAsState` 로 관찰하는가 +- [ ] XML 쪽 `fitsSystemWindows` 와 Compose 의 `*Padding()` 이 중복되지 않는가 +- [ ] Preview 의 색·폰트가 실 실행과 일치하는가 (불일치 = 테마 감싸기 누락 신호) From 05faf5f96e39abd106866c1e4e8f5acf7e534893 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 24 Apr 2026 16:32:07 +0900 Subject: [PATCH 06/99] =?UTF-8?q?fix:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#489?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/components/button/Button.kt | 37 ++--- .../components/button/ButtonPreview.kt | 51 ++++--- .../compose/components/display/Avatar.kt | 26 ++-- .../compose/components/display/Badge.kt | 26 ++-- .../compose/components/display/Card.kt | 14 +- .../components/display/DisplayPreview.kt | 97 +++++++------ .../components/feedback/AlertDialog.kt | 16 ++- .../compose/components/input/TextField.kt | 134 ++++++++++-------- .../components/input/TextFieldPreview.kt | 55 +++---- .../compose/components/layout/TopBar.kt | 30 ++-- .../presentation/compose/theme/Shape.kt | 13 +- .../presentation/compose/theme/Theme.kt | 81 +++++------ .../compose/theme/ThemePreview.kt | 133 +++++++++-------- .../presentation/compose/theme/Type.kt | 120 +++++++++------- 14 files changed, 448 insertions(+), 385 deletions(-) diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt b/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt index d739bccc..4cdb68de 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/button/Button.kt @@ -35,12 +35,13 @@ fun PrimaryButton( Button( onClick = onClick, modifier = modifier.height(height), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = ColorGray200, - disabledContentColor = ColorWhite300, - ), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = ColorGray200, + disabledContentColor = ColorWhite300, + ), shape = MaterialTheme.shapes.large, enabled = enabled, contentPadding = PaddingValues(horizontal = 16.dp, vertical = if (isCompact) 8.dp else 12.dp), @@ -70,12 +71,13 @@ fun SecondaryButton( Button( onClick = onClick, modifier = modifier.height(height), - colors = ButtonDefaults.buttonColors( - containerColor = ColorGray300, - contentColor = ColorWhite300, - disabledContainerColor = ColorGray200, - disabledContentColor = ColorWhite300, - ), + colors = + ButtonDefaults.buttonColors( + containerColor = ColorGray300, + contentColor = ColorWhite300, + disabledContainerColor = ColorGray200, + disabledContentColor = ColorWhite300, + ), shape = MaterialTheme.shapes.large, enabled = enabled, contentPadding = PaddingValues(horizontal = 16.dp, vertical = if (isCompact) 8.dp else 12.dp), @@ -104,11 +106,12 @@ fun TextButton( OutlinedButton( onClick = onClick, modifier = modifier.height(if (isCompact) 32.dp else 48.dp), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = ColorWhite100, - contentColor = MaterialTheme.colorScheme.primary, - disabledContentColor = ColorGray200, - ), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = ColorWhite100, + contentColor = MaterialTheme.colorScheme.primary, + disabledContentColor = ColorGray200, + ), shape = MaterialTheme.shapes.small, enabled = enabled, contentPadding = PaddingValues(horizontal = 12.dp, vertical = if (isCompact) 4.dp else 8.dp), diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/button/ButtonPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/button/ButtonPreview.kt index cd4e3405..d5a523e8 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/button/ButtonPreview.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/button/ButtonPreview.kt @@ -16,19 +16,20 @@ import com.project200.presentation.compose.theme.AppTheme fun PrimaryButtonPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { PrimaryButton( text = "완료", - onClick = {} + onClick = {}, ) Spacer(modifier = Modifier.height(12.dp)) PrimaryButton( text = "완료", onClick = {}, - enabled = false + enabled = false, ) } } @@ -39,21 +40,22 @@ fun PrimaryButtonPreview() { fun PrimaryButtonCompactPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { PrimaryButton( text = "완료", onClick = {}, - isCompact = true + isCompact = true, ) Spacer(modifier = Modifier.height(12.dp)) PrimaryButton( text = "완료", onClick = {}, isCompact = true, - enabled = false + enabled = false, ) } } @@ -64,19 +66,20 @@ fun PrimaryButtonCompactPreview() { fun SecondaryButtonPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { SecondaryButton( text = "취소", - onClick = {} + onClick = {}, ) Spacer(modifier = Modifier.height(12.dp)) SecondaryButton( text = "취소", onClick = {}, - enabled = false + enabled = false, ) } } @@ -87,19 +90,20 @@ fun SecondaryButtonPreview() { fun TextButtonPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { TextButton( text = "중복 확인", - onClick = {} + onClick = {}, ) Spacer(modifier = Modifier.height(12.dp)) TextButton( text = "중복 확인", onClick = {}, - enabled = false + enabled = false, ) } } @@ -110,9 +114,10 @@ fun TextButtonPreview() { fun ButtonGroupPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { PrimaryButton(text = "확인", onClick = {}) Spacer(modifier = Modifier.height(8.dp)) diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt index 225f27ee..7f4081e9 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/Avatar.kt @@ -38,15 +38,16 @@ fun UndabangAvatar( contentDescription: String = "프로필 이미지", ) { Box( - modifier = modifier - .size(size) - .clip(CircleShape) - .background(color = placeholderBackground, shape = CircleShape) - .border( - width = borderWidth, - color = borderColor, - shape = CircleShape, - ), + modifier = + modifier + .size(size) + .clip(CircleShape) + .background(color = placeholderBackground, shape = CircleShape) + .border( + width = borderWidth, + color = borderColor, + shape = CircleShape, + ), contentAlignment = Alignment.Center, ) { if (imageUrl.isNullOrBlank()) { @@ -60,9 +61,10 @@ fun UndabangAvatar( AsyncImage( model = imageUrl, contentDescription = contentDescription, - modifier = Modifier - .size(size - borderWidth * 2) - .clip(CircleShape), + modifier = + Modifier + .size(size - borderWidth * 2) + .clip(CircleShape), contentScale = ContentScale.Crop, ) } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt index da3261d3..1b25f441 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/Badge.kt @@ -29,12 +29,13 @@ fun UndabangBadge( size: Dp = 24.dp, ) { Box( - modifier = modifier - .size(size) - .background( - color = backgroundColor, - shape = CircleShape, - ), + modifier = + modifier + .size(size) + .background( + color = backgroundColor, + shape = CircleShape, + ), contentAlignment = Alignment.Center, ) { Text( @@ -58,11 +59,12 @@ fun UndabangDot( size: Dp = 12.dp, ) { Box( - modifier = modifier - .size(size) - .background( - color = color, - shape = CircleShape, - ), + modifier = + modifier + .size(size) + .background( + color = color, + shape = CircleShape, + ), ) } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/Card.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/Card.kt index 2c1d258c..e800505f 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/display/Card.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/Card.kt @@ -23,13 +23,15 @@ fun UndabangCard( ) { Card( modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = backgroundColor, - ), + colors = + CardDefaults.cardColors( + containerColor = backgroundColor, + ), shape = MaterialTheme.shapes.medium, - elevation = CardDefaults.cardElevation( - defaultElevation = 4.dp, - ), + elevation = + CardDefaults.cardElevation( + defaultElevation = 4.dp, + ), ) { content() } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt index 1f8e86b4..411a7e11 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/display/DisplayPreview.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -28,18 +27,19 @@ import com.project200.presentation.compose.theme.ColorWhite300 fun AvatarPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text("프로필 아바타", fontWeight = FontWeight.Bold, fontSize = 16.sp) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { UndabangAvatar(imageUrl = null, size = 32.dp) UndabangAvatar(imageUrl = null, size = 48.dp) @@ -56,54 +56,57 @@ fun AvatarPreview() { fun CardPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text("카드 컴포넌트", fontWeight = FontWeight.Bold, fontSize = 16.sp) UndabangCard( - backgroundColor = ColorWhite300 + backgroundColor = ColorWhite300, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { Text( text = "운동 종류", fontSize = 14.sp, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = "헬스", fontSize = 12.sp, - color = Color.Gray + color = Color.Gray, ) } } UndabangCard( - backgroundColor = ColorMain.copy(alpha = 0.1f) + backgroundColor = ColorMain.copy(alpha = 0.1f), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { Text( text = "점수", fontSize = 14.sp, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = "85점", fontSize = 18.sp, fontWeight = FontWeight.Bold, - color = ColorMain + color = ColorMain, ) } } @@ -116,18 +119,19 @@ fun CardPreview() { fun BadgePreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text("뱃지 컴포넌트", fontWeight = FontWeight.Bold, fontSize = 16.sp) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { UndabangBadge(count = 1) UndabangBadge(count = 5) @@ -145,18 +149,19 @@ fun BadgePreview() { fun DotPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text("도트 표시기", fontWeight = FontWeight.Bold, fontSize = 16.sp) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { UndabangDot(size = 8.dp) UndabangDot(size = 12.dp) @@ -173,36 +178,38 @@ fun DotPreview() { fun DisplayComponentsGroupPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .background(ColorGray300) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = + Modifier + .fillMaxWidth() + .background(ColorGray300) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text("Display 컴포넌트 조합", fontWeight = FontWeight.Bold, fontSize = 16.sp) Row( - modifier = Modifier - .fillMaxWidth() - .background(ColorWhite300, shape = androidx.compose.material3.MaterialTheme.shapes.medium) - .padding(12.dp), + modifier = + Modifier + .fillMaxWidth() + .background(ColorWhite300, shape = androidx.compose.material3.MaterialTheme.shapes.medium) + .padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { UndabangAvatar(imageUrl = null, size = 48.dp) Column( - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( text = "사용자 이름", fontWeight = FontWeight.Bold, - fontSize = 14.sp + fontSize = 14.sp, ) Text( text = "최근 활동: 30분 전", fontSize = 12.sp, - color = Color.Gray + color = Color.Gray, ) } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt index f846b64e..02804e5b 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/feedback/AlertDialog.kt @@ -46,10 +46,11 @@ fun UndabangAlertDialog( ) { Dialog( onDismissRequest = onDismiss, - properties = DialogProperties( - dismissOnBackPress = isCancelable, - dismissOnClickOutside = isCancelable, - ), + properties = + DialogProperties( + dismissOnBackPress = isCancelable, + dismissOnClickOutside = isCancelable, + ), ) { Surface( modifier = modifier.fillMaxWidth(0.85f), @@ -57,9 +58,10 @@ fun UndabangAlertDialog( color = ColorWhite300, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(32.dp), ) { diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt index 23a714d8..d7d096a6 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextField.kt @@ -71,59 +71,64 @@ fun UndabangTextField( ) { val interactionSource = remember { MutableInteractionSource() } - val style = when (variant) { - TextFieldVariant.FilledSmall -> TextFieldStyle( - backgroundColor = ColorWhite100, - cornerRadius = 8.dp, - borderColor = null, - borderWidth = null, - padding = 12.dp, - minHeight = 48.dp, - hintColor = ColorGray200, - ) - TextFieldVariant.FilledLarge -> TextFieldStyle( - backgroundColor = ColorWhite100, - cornerRadius = 8.dp, - borderColor = null, - borderWidth = null, - padding = 16.dp, - minHeight = 0.dp, - hintColor = ColorGray200, - ) - TextFieldVariant.Outlined -> TextFieldStyle( - backgroundColor = ColorWhite100, - cornerRadius = 8.dp, - borderColor = if (isError) ColorErrorRed else ColorGray200, - borderWidth = 1.dp, - padding = 12.dp, - minHeight = 48.dp, - hintColor = ColorGray200, - ) - } - - Column(modifier = modifier) { - Box( - modifier = Modifier - .fillMaxWidth() - .then( - if (style.minHeight > 0.dp) Modifier.height(style.minHeight) else Modifier, + val style = + when (variant) { + TextFieldVariant.FilledSmall -> + TextFieldStyle( + backgroundColor = ColorWhite100, + cornerRadius = 8.dp, + borderColor = null, + borderWidth = null, + padding = 12.dp, + minHeight = 48.dp, + hintColor = ColorGray200, ) - .background( - color = if (enabled) style.backgroundColor else ColorGray300, - shape = RoundedCornerShape(style.cornerRadius), + TextFieldVariant.FilledLarge -> + TextFieldStyle( + backgroundColor = ColorWhite100, + cornerRadius = 8.dp, + borderColor = null, + borderWidth = null, + padding = 16.dp, + minHeight = 0.dp, + hintColor = ColorGray200, ) - .then( - if (style.borderColor != null && style.borderWidth != null) { - Modifier.border( - width = style.borderWidth, - color = style.borderColor, - shape = RoundedCornerShape(style.cornerRadius), - ) - } else { - Modifier - }, + TextFieldVariant.Outlined -> + TextFieldStyle( + backgroundColor = ColorWhite100, + cornerRadius = 8.dp, + borderColor = if (isError) ColorErrorRed else ColorGray200, + borderWidth = 1.dp, + padding = 12.dp, + minHeight = 48.dp, + hintColor = ColorGray200, ) - .padding(style.padding), + } + + Column(modifier = modifier) { + Box( + modifier = + Modifier + .fillMaxWidth() + .then( + if (style.minHeight > 0.dp) Modifier.height(style.minHeight) else Modifier, + ) + .background( + color = if (enabled) style.backgroundColor else ColorGray300, + shape = RoundedCornerShape(style.cornerRadius), + ) + .then( + if (style.borderColor != null && style.borderWidth != null) { + Modifier.border( + width = style.borderWidth, + color = style.borderColor, + shape = RoundedCornerShape(style.cornerRadius), + ) + } else { + Modifier + }, + ) + .padding(style.padding), contentAlignment = Alignment.CenterStart, ) { BasicTextField( @@ -132,20 +137,22 @@ fun UndabangTextField( val next = if (maxLength != null) newValue.take(maxLength) else newValue if (next != value) onValueChange(next) }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focusState -> onFocusChange(focusState.isFocused) } - .then( - if (style.minHeight > 0.dp) Modifier else Modifier.defaultMinSize(minHeight = 56.dp), - ), + modifier = + Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> onFocusChange(focusState.isFocused) } + .then( + if (style.minHeight > 0.dp) Modifier else Modifier.defaultMinSize(minHeight = 56.dp), + ), singleLine = singleLine, maxLines = maxLines, minLines = minLines, keyboardOptions = keyboardOptions, - textStyle = LocalTextStyle.current.copy( - fontSize = 14.sp, - color = if (enabled) ColorBlack else ColorGray200, - ), + textStyle = + LocalTextStyle.current.copy( + fontSize = 14.sp, + color = if (enabled) ColorBlack else ColorGray200, + ), enabled = enabled && !readOnly, readOnly = readOnly, visualTransformation = visualTransformation, @@ -168,9 +175,10 @@ fun UndabangTextField( if (supportingText.isNotEmpty()) { Text( text = supportingText, - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, start = style.padding, end = style.padding), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp, start = style.padding, end = style.padding), color = if (isError) ColorErrorRed else ColorMain, fontSize = 12.sp, ) diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt index 2c81a4a6..81c44fb5 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/input/TextFieldPreview.kt @@ -23,16 +23,17 @@ fun TextFieldFilledSmallPreview() { AppTheme { var value by remember { mutableStateOf("") } Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { UndabangTextField( value = value, onValueChange = { value = it }, hint = "닉네임을 입력하세요", variant = TextFieldVariant.FilledSmall, - singleLine = true + singleLine = true, ) Spacer(modifier = Modifier.height(12.dp)) UndabangTextField( @@ -41,7 +42,7 @@ fun TextFieldFilledSmallPreview() { hint = "닉네임을 입력하세요", variant = TextFieldVariant.FilledSmall, singleLine = true, - enabled = false + enabled = false, ) } } @@ -53,9 +54,10 @@ fun TextFieldFilledLargePreview() { AppTheme { var value by remember { mutableStateOf("") } Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { UndabangTextField( value = value, @@ -63,7 +65,7 @@ fun TextFieldFilledLargePreview() { hint = "긴 내용을 입력하세요", variant = TextFieldVariant.FilledLarge, singleLine = false, - maxLines = 5 + maxLines = 5, ) Spacer(modifier = Modifier.height(12.dp)) UndabangTextField( @@ -72,7 +74,7 @@ fun TextFieldFilledLargePreview() { hint = "긴 내용을 입력하세요", variant = TextFieldVariant.FilledLarge, singleLine = false, - enabled = false + enabled = false, ) } } @@ -84,16 +86,17 @@ fun TextFieldOutlinedPreview() { AppTheme { var value by remember { mutableStateOf("") } Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { UndabangTextField( value = value, onValueChange = { value = it }, hint = "장소명을 입력하세요", variant = TextFieldVariant.Outlined, - singleLine = true + singleLine = true, ) Spacer(modifier = Modifier.height(12.dp)) UndabangTextField( @@ -103,7 +106,7 @@ fun TextFieldOutlinedPreview() { variant = TextFieldVariant.Outlined, singleLine = true, isError = true, - supportingText = "중복된 장소입니다" + supportingText = "중복된 장소입니다", ) } } @@ -116,9 +119,10 @@ fun TextFieldNumericPreview() { var minute by remember { mutableStateOf("10") } var second by remember { mutableStateOf("30") } Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { UndabangTextField( value = minute, @@ -127,7 +131,7 @@ fun TextFieldNumericPreview() { variant = TextFieldVariant.FilledSmall, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - maxLength = 2 + maxLength = 2, ) Spacer(modifier = Modifier.height(12.dp)) UndabangTextField( @@ -137,7 +141,7 @@ fun TextFieldNumericPreview() { variant = TextFieldVariant.FilledSmall, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - maxLength = 2 + maxLength = 2, ) } } @@ -149,9 +153,10 @@ fun TextFieldWithSupportingTextPreview() { AppTheme { var value by remember { mutableStateOf("undabang2024") } Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { UndabangTextField( value = value, @@ -159,7 +164,7 @@ fun TextFieldWithSupportingTextPreview() { hint = "닉네임", variant = TextFieldVariant.FilledSmall, singleLine = true, - supportingText = "사용 가능한 닉네임입니다" + supportingText = "사용 가능한 닉네임입니다", ) Spacer(modifier = Modifier.height(16.dp)) UndabangTextField( @@ -169,7 +174,7 @@ fun TextFieldWithSupportingTextPreview() { variant = TextFieldVariant.FilledSmall, singleLine = true, isError = true, - supportingText = "이미 사용 중인 닉네임입니다" + supportingText = "이미 사용 중인 닉네임입니다", ) } } diff --git a/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt index e9bb005e..671a7eb3 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/components/layout/TopBar.kt @@ -45,16 +45,18 @@ fun UndabangTopBar( actions: @Composable RowScope.() -> Unit = {}, ) { Box( - modifier = modifier - .fillMaxWidth() - .height(65.dp) - .background(color = ColorWhite300), + modifier = + modifier + .fillMaxWidth() + .height(65.dp) + .background(color = ColorWhite300), ) { Text( text = title, - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 64.dp), + modifier = + Modifier + .align(Alignment.Center) + .padding(horizontal = 64.dp), style = MaterialTheme.typography.contentBold, color = ColorBlack, textAlign = TextAlign.Center, @@ -63,9 +65,10 @@ fun UndabangTopBar( ) Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 4.dp), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { if (navigationIconVisible) { @@ -84,9 +87,10 @@ fun UndabangTopBar( } Row( - modifier = Modifier - .weight(1f) - .wrapContentWidth(align = Alignment.End), + modifier = + Modifier + .weight(1f) + .wrapContentWidth(align = Alignment.End), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, content = actions, diff --git a/presentation/src/main/java/com/project200/presentation/compose/theme/Shape.kt b/presentation/src/main/java/com/project200/presentation/compose/theme/Shape.kt index 4ea31053..976c57b7 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/theme/Shape.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/theme/Shape.kt @@ -4,9 +4,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp -val AppShapes = Shapes( - small = RoundedCornerShape(5.dp), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(15.dp), - extraLarge = RoundedCornerShape(15.dp), -) +val AppShapes = + Shapes( + small = RoundedCornerShape(5.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(15.dp), + extraLarge = RoundedCornerShape(15.dp), + ) diff --git a/presentation/src/main/java/com/project200/presentation/compose/theme/Theme.kt b/presentation/src/main/java/com/project200/presentation/compose/theme/Theme.kt index 77ee75aa..a1ffee73 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/theme/Theme.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/theme/Theme.kt @@ -3,61 +3,62 @@ package com.project200.presentation.compose.theme import android.os.Build import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -private val LightColorScheme = ColorScheme( - primary = ColorMain, - onPrimary = ColorWhite300, - primaryContainer = ColorMainBackground, - onPrimaryContainer = ColorMain, - inversePrimary = ColorWhite300, - secondary = ColorGray200, - onSecondary = ColorWhite300, - secondaryContainer = ColorGray300, - onSecondaryContainer = ColorGray100, - tertiary = ColorMain, - onTertiary = ColorWhite300, - tertiaryContainer = ColorMainBackground, - onTertiaryContainer = ColorMain, - error = ColorErrorRed, - onError = ColorWhite300, - errorContainer = Color(0xFFF9DEDC), - onErrorContainer = ColorErrorRed, - background = ColorBackground, - onBackground = ColorBlack, - surface = ColorSurface, - onSurface = ColorBlack, - surfaceVariant = ColorSurfaceVariant, - onSurfaceVariant = ColorGray100, - surfaceTint = ColorMain, - inverseSurface = ColorBlack, - inverseOnSurface = ColorWhite300, - outline = ColorOutline, - outlineVariant = ColorGray300, - scrim = Color(0xFF000000) -) +private val LightColorScheme = + ColorScheme( + primary = ColorMain, + onPrimary = ColorWhite300, + primaryContainer = ColorMainBackground, + onPrimaryContainer = ColorMain, + inversePrimary = ColorWhite300, + secondary = ColorGray200, + onSecondary = ColorWhite300, + secondaryContainer = ColorGray300, + onSecondaryContainer = ColorGray100, + tertiary = ColorMain, + onTertiary = ColorWhite300, + tertiaryContainer = ColorMainBackground, + onTertiaryContainer = ColorMain, + error = ColorErrorRed, + onError = ColorWhite300, + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = ColorErrorRed, + background = ColorBackground, + onBackground = ColorBlack, + surface = ColorSurface, + onSurface = ColorBlack, + surfaceVariant = ColorSurfaceVariant, + onSurfaceVariant = ColorGray100, + surfaceTint = ColorMain, + inverseSurface = ColorBlack, + inverseOnSurface = ColorWhite300, + outline = ColorOutline, + outlineVariant = ColorGray300, + scrim = Color(0xFF000000), + ) @Composable fun AppTheme( dynamicColor: Boolean = false, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - dynamicLightColorScheme(context) + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + dynamicLightColorScheme(context) + } + else -> LightColorScheme } - else -> LightColorScheme - } MaterialTheme( colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, - content = content + content = content, ) } diff --git a/presentation/src/main/java/com/project200/presentation/compose/theme/ThemePreview.kt b/presentation/src/main/java/com/project200/presentation/compose/theme/ThemePreview.kt index c78c71e9..28357360 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/theme/ThemePreview.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/theme/ThemePreview.kt @@ -26,15 +26,16 @@ import androidx.compose.ui.unit.sp fun ColorPalettePreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( text = "컬러 팔레트", fontWeight = FontWeight.Bold, - fontSize = 18.sp + fontSize = 18.sp, ) ColorRow("Main Color", ColorMain) @@ -52,7 +53,7 @@ fun ColorPalettePreview() { Text( text = "점수 레벨", fontWeight = FontWeight.Bold, - fontSize = 16.sp + fontSize = 16.sp, ) ColorRow("High Level", ColorScoreHighLevel) ColorRow("Middle Level", ColorScoreMiddleLevel) @@ -62,27 +63,31 @@ fun ColorPalettePreview() { } @Composable -private fun ColorRow(label: String, color: Color) { +private fun ColorRow( + label: String, + color: Color, +) { Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = color, - shape = RoundedCornerShape(8.dp) - ) - .padding(12.dp), + modifier = + Modifier + .fillMaxWidth() + .background( + color = color, + shape = RoundedCornerShape(8.dp), + ) + .padding(12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = label, color = Color.White, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) Text( text = "#${color.toArgb().toUInt().toString(16).uppercase().takeLast(6)}", color = Color.White, - fontSize = 12.sp + fontSize = 12.sp, ) } } @@ -92,45 +97,46 @@ private fun ColorRow(label: String, color: Color) { fun TypographyPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( text = "타이포그래피", fontWeight = FontWeight.Bold, - fontSize = 18.sp + fontSize = 18.sp, ) Text( text = "Display Large (22sp, Bold)", - style = AppTypography.displayLarge + style = AppTypography.displayLarge, ) Text( text = "Display Medium (18sp, Bold)", - style = AppTypography.displayMedium + style = AppTypography.displayMedium, ) Text( text = "Body Large (15sp, Normal)", - style = AppTypography.bodyLarge + style = AppTypography.bodyLarge, ) Text( text = "Body Medium (14sp, Normal)", - style = AppTypography.bodyMedium + style = AppTypography.bodyMedium, ) Text( text = "Body Small (12sp, Normal)", - style = AppTypography.bodySmall + style = AppTypography.bodySmall, ) Text( text = "Label Large (15sp, SemiBold)", - style = AppTypography.labelLarge + style = AppTypography.labelLarge, ) } } @@ -141,29 +147,31 @@ fun TypographyPreview() { fun ShapesPreview() { AppTheme { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( text = "쉐이프/코너", fontWeight = FontWeight.Bold, - fontSize = 18.sp + fontSize = 18.sp, ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier - .size(60.dp) - .background( - color = ColorMain, - shape = AppShapes.small - ) + modifier = + Modifier + .size(60.dp) + .background( + color = ColorMain, + shape = AppShapes.small, + ), ) Text("Small (5dp)") } @@ -171,15 +179,16 @@ fun ShapesPreview() { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier - .size(60.dp) - .background( - color = ColorMain, - shape = AppShapes.medium - ) + modifier = + Modifier + .size(60.dp) + .background( + color = ColorMain, + shape = AppShapes.medium, + ), ) Text("Medium (8dp)") } @@ -187,15 +196,16 @@ fun ShapesPreview() { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier - .size(60.dp) - .background( - color = ColorMain, - shape = AppShapes.large - ) + modifier = + Modifier + .size(60.dp) + .background( + color = ColorMain, + shape = AppShapes.large, + ), ) Text("Large (12dp)") } @@ -203,15 +213,16 @@ fun ShapesPreview() { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier - .size(60.dp) - .background( - color = ColorMain, - shape = AppShapes.extraLarge - ) + modifier = + Modifier + .size(60.dp) + .background( + color = ColorMain, + shape = AppShapes.extraLarge, + ), ) Text("ExtraLarge (16dp)") } diff --git a/presentation/src/main/java/com/project200/presentation/compose/theme/Type.kt b/presentation/src/main/java/com/project200/presentation/compose/theme/Type.kt index ea6d89bc..0a5d30c2 100644 --- a/presentation/src/main/java/com/project200/presentation/compose/theme/Type.kt +++ b/presentation/src/main/java/com/project200/presentation/compose/theme/Type.kt @@ -8,62 +8,72 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import com.project200.undabang.presentation.R -val PretendardFontFamily = FontFamily( - Font(R.font.pretendard_bold, FontWeight.Bold), - Font(R.font.pretendard_medium, FontWeight.Medium), - Font(R.font.pretendard_regular, FontWeight.Normal), -) +val PretendardFontFamily = + FontFamily( + Font(R.font.pretendard_bold, FontWeight.Bold), + Font(R.font.pretendard_medium, FontWeight.Medium), + Font(R.font.pretendard_regular, FontWeight.Normal), + ) -val AppTypography = Typography( - displayLarge = TextStyle( - fontFamily = PretendardFontFamily, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - lineHeight = 26.4.sp, - ), - displayMedium = TextStyle( - fontFamily = PretendardFontFamily, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - lineHeight = 21.6.sp, - ), - titleMedium = TextStyle( - fontFamily = PretendardFontFamily, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - lineHeight = 18.sp, - ), - bodyLarge = TextStyle( - fontFamily = PretendardFontFamily, - fontSize = 15.sp, - fontWeight = FontWeight.Normal, - lineHeight = 18.sp, - ), - bodyMedium = TextStyle( - fontFamily = PretendardFontFamily, - fontSize = 14.sp, - fontWeight = FontWeight.Normal, - lineHeight = 16.8.sp, - ), - bodySmall = TextStyle( - fontFamily = PretendardFontFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Normal, - lineHeight = 14.4.sp, - ), - labelSmall = TextStyle( - fontFamily = PretendardFontFamily, - fontSize = 10.sp, - fontWeight = FontWeight.Normal, - lineHeight = 12.sp, - ), - labelLarge = TextStyle( - fontFamily = PretendardFontFamily, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 18.sp, - ), -) +val AppTypography = + Typography( + displayLarge = + TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + lineHeight = 26.4.sp, + ), + displayMedium = + TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + lineHeight = 21.6.sp, + ), + titleMedium = + TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + lineHeight = 18.sp, + ), + bodyLarge = + TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + lineHeight = 18.sp, + ), + bodyMedium = + TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + lineHeight = 16.8.sp, + ), + bodySmall = + TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + lineHeight = 14.4.sp, + ), + labelSmall = + TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 10.sp, + fontWeight = FontWeight.Normal, + lineHeight = 12.sp, + ), + labelLarge = + TextStyle( + fontFamily = PretendardFontFamily, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 18.sp, + ), + ) // XML styles.xml 이름으로 접근할 수 있는 확장 프로퍼티 // 사용: MaterialTheme.typography.header From 6bc4442b4831ebf30b4c497e126758fe0262da93 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 24 Apr 2026 16:43:58 +0900 Subject: [PATCH 07/99] =?UTF-8?q?chore:=20ktlint=20=EC=97=90=EC=84=9C=20@C?= =?UTF-8?q?omposable=20=ED=95=A8=EC=88=98=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?#489?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .editorconfig | 1 + 1 file changed, 1 insertion(+) diff --git a/.editorconfig b/.editorconfig index 4df92550..6dc46cbb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,6 +3,7 @@ root = true [*.{kt,kts}] ktlint_standard_filename = enabled ktlint_standard_no-consecutive-comments = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable From f3793efd72124d42222f0d65f876e4434c95149b Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 24 Apr 2026 17:40:55 +0900 Subject: [PATCH 08/99] =?UTF-8?q?refactor:=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20Compose=20=EB=A1=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20#491?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ComposeView.applyAppTheme 확장 함수 추가 - SettingScreen / SettingMenuItem Compose 작성 - SettingFragment -> ComposeView 반환 구조 - fragment_setting.xml 삭제 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../profile/setting/SettingFragment.kt | 81 +++--- .../undabang/profile/setting/SettingScreen.kt | 167 +++++++++++++ .../src/main/res/layout/fragment_setting.xml | 230 ------------------ .../presentation/compose/ComposeViewExt.kt | 23 ++ 4 files changed, 239 insertions(+), 262 deletions(-) create mode 100644 feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingScreen.kt delete mode 100644 feature/profile/src/main/res/layout/fragment_setting.xml create mode 100644 presentation/src/main/java/com/project200/presentation/compose/ComposeViewExt.kt diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingFragment.kt index 46ffe3b2..2f289796 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingFragment.kt @@ -2,21 +2,24 @@ package com.project200.undabang.profile.setting import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.project200.presentation.base.BaseAlertDialog -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.presentation.navigator.ActivityNavigator import com.project200.presentation.terms.TermsDialogFragment import com.project200.presentation.terms.TermsDialogFragment.Companion.PRIVACY import com.project200.presentation.terms.TermsDialogFragment.Companion.TERMS import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentSettingBinding import com.project200.undabang.oauth.AuthManager import com.project200.undabang.oauth.LogoutResultCallback import dagger.hilt.android.AndroidEntryPoint @@ -26,18 +29,13 @@ import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint -class SettingFragment : BindingFragment(R.layout.fragment_setting) { - override fun getViewBinding(view: View): FragmentSettingBinding { - return FragmentSettingBinding.bind(view) - } - +class SettingFragment : Fragment() { @Inject lateinit var appNavigator: ActivityNavigator @Inject lateinit var authManager: AuthManager private lateinit var authService: AuthorizationService private val viewModel: SettingViewModel by viewModels() - // Cognito 로그아웃 페이지를 열기 위한 ActivityResultLauncher private val logoutPageLauncher: ActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> Timber.d("로그아웃: ${result.resultCode}") @@ -49,34 +47,54 @@ class SettingFragment : BindingFragment(R.layout.fragmen authService = AuthorizationService(requireContext()) } - override fun setupViews() = - with(binding) { - versionInfoTv.text = requireActivity().packageManager.getPackageInfo(requireContext().packageName, 0).versionName + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val versionName = + requireActivity().packageManager + .getPackageInfo(requireContext().packageName, 0) + .versionName + .orEmpty() - backBtnIv.setOnClickListener { findNavController().popBackStack() } - logoutLl.setOnClickListener { - BaseAlertDialog(getString(R.string.alert_logout), null) { - performLogout() - }.show(parentFragmentManager, BaseAlertDialog::class.java.simpleName) - } - blockMembersLl.setOnClickListener { - findNavController().navigate(R.id.action_settingFragment_to_blockMembersFragment) - } - withdrawLl.setOnClickListener { // 회원탈퇴 웹 페이지로 이동 - appNavigator.navigateToWeb(requireContext(), getString(R.string.withdraw_url)) - } - customerServiceLl.setOnClickListener { // 고객센터 웹 페이지로 이동 - appNavigator.navigateToWeb(requireContext(), getString(R.string.customer_service_url)) - } - termsLl.setOnClickListener { showTermsDialog(TERMS) } - privacyLl.setOnClickListener { showTermsDialog(PRIVACY) } - notificationLl.setOnClickListener { - findNavController().navigate(R.id.action_settingFragment_to_notificationFragment) + return ComposeView(requireContext()).apply { + applyAppTheme { + SettingScreen( + versionName = versionName, + onNavigateBack = { findNavController().popBackStack() }, + onCustomerServiceClick = { + appNavigator.navigateToWeb( + requireContext(), + getString(R.string.customer_service_url), + ) + }, + onLogoutClick = { + BaseAlertDialog(getString(R.string.alert_logout), null) { + performLogout() + }.show(parentFragmentManager, BaseAlertDialog::class.java.simpleName) + }, + onWithdrawClick = { + appNavigator.navigateToWeb( + requireContext(), + getString(R.string.withdraw_url), + ) + }, + onBlockMembersClick = { + findNavController().navigate(R.id.action_settingFragment_to_blockMembersFragment) + }, + onTermsClick = { showTermsDialog(TERMS) }, + onPrivacyClick = { showTermsDialog(PRIVACY) }, + onNotificationClick = { + findNavController().navigate(R.id.action_settingFragment_to_notificationFragment) + }, + ) } } + } private fun performLogout() { - viewLifecycleOwner.lifecycleScope.launch { // Fragment의 viewLifecycleOwner 사용 + viewLifecycleOwner.lifecycleScope.launch { try { viewModel.logout() } catch (e: Exception) { @@ -97,7 +115,6 @@ class SettingFragment : BindingFragment(R.layout.fragmen override fun onLogoutProcessError(exception: Exception) { Timber.e(exception, "로그아웃 에러: ${exception.message}") Toast.makeText(requireContext(), getString(R.string.logout_error), Toast.LENGTH_LONG).show() - // 오류 발생 시에도 로컬 상태는 AuthManager에서 정리 시도됨. 로그인 화면으로 이동. appNavigator.navigateToLogin(requireContext()) } }, diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingScreen.kt new file mode 100644 index 00000000..b91a600a --- /dev/null +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingScreen.kt @@ -0,0 +1,167 @@ +package com.project200.undabang.profile.setting + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentRegular +import com.project200.undabang.feature.profile.R +import com.project200.undabang.presentation.R as PresentationR + +@Composable +fun SettingScreen( + versionName: String, + onNavigateBack: () -> Unit, + onCustomerServiceClick: () -> Unit, + onLogoutClick: () -> Unit, + onWithdrawClick: () -> Unit, + onBlockMembersClick: () -> Unit, + onTermsClick: () -> Unit, + onPrivacyClick: () -> Unit, + onNotificationClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(ColorWhite300), + ) { + com.project200.presentation.compose.components.layout.UndabangTopBar( + title = stringResource(R.string.setting), + onNavigationClick = onNavigateBack, + ) + + SettingMenuItem( + iconRes = PresentationR.drawable.ic_customer_service, + labelRes = R.string.customer_service, + onClick = onCustomerServiceClick, + ) + SettingMenuItem( + iconRes = PresentationR.drawable.ic_logout, + labelRes = R.string.logout, + onClick = onLogoutClick, + ) + SettingMenuItem( + iconRes = PresentationR.drawable.ic_withdraw, + labelRes = R.string.withdraw, + onClick = onWithdrawClick, + ) + SettingMenuItem( + iconRes = PresentationR.drawable.ic_block, + labelRes = R.string.block_members, + onClick = onBlockMembersClick, + ) + SettingMenuItem( + iconRes = PresentationR.drawable.ic_document, + labelRes = R.string.terms, + onClick = onTermsClick, + ) + SettingMenuItem( + iconRes = PresentationR.drawable.ic_document, + labelRes = R.string.privacy, + onClick = onPrivacyClick, + ) + SettingMenuItem( + iconRes = R.drawable.ic_notification, + labelRes = R.string.notification, + onClick = onNotificationClick, + ) + SettingMenuItem( + iconRes = PresentationR.drawable.ic_version_info, + labelRes = R.string.version_info, + trailingText = versionName, + showDivider = false, + ) + } +} + +@Composable +private fun SettingMenuItem( + @DrawableRes iconRes: Int, + @StringRes labelRes: Int, + modifier: Modifier = Modifier, + trailingText: String? = null, + showDivider: Boolean = true, + onClick: (() -> Unit)? = null, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(horizontal = 20.dp, vertical = 19.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f, fill = false), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = ColorBlack, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(id = labelRes), + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.contentRegular, + color = ColorBlack, + ) + } + if (trailingText != null) { + Text( + text = trailingText, + style = MaterialTheme.typography.contentRegular, + color = ColorGray200, + ) + } + } + if (showDivider) { + HorizontalDivider(thickness = 0.3.dp, color = ColorBlack) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingScreenPreview() { + AppTheme { + SettingScreen( + versionName = "0.8.3", + onNavigateBack = {}, + onCustomerServiceClick = {}, + onLogoutClick = {}, + onWithdrawClick = {}, + onBlockMembersClick = {}, + onTermsClick = {}, + onPrivacyClick = {}, + onNotificationClick = {}, + ) + } +} diff --git a/feature/profile/src/main/res/layout/fragment_setting.xml b/feature/profile/src/main/res/layout/fragment_setting.xml deleted file mode 100644 index e728502c..00000000 --- a/feature/profile/src/main/res/layout/fragment_setting.xml +++ /dev/null @@ -1,230 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/presentation/src/main/java/com/project200/presentation/compose/ComposeViewExt.kt b/presentation/src/main/java/com/project200/presentation/compose/ComposeViewExt.kt new file mode 100644 index 00000000..33b3ccab --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/compose/ComposeViewExt.kt @@ -0,0 +1,23 @@ +package com.project200.presentation.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.project200.presentation.compose.theme.AppTheme + +/** + * ComposeView 진입점에서 AppTheme 감싸기와 컴포지션 전략 설정을 강제하는 확장. + * + * Fragment 안에서 ComposeView 를 쓸 때 `DisposeOnViewTreeLifecycleDestroyed` 가 + * 안정적이며, AppTheme 을 빠뜨리면 Material3 기본 색(보라)으로 렌더링된다. + */ +fun ComposeView.applyAppTheme(content: @Composable () -> Unit) { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, + ) + setContent { + AppTheme { + content() + } + } +} From 48ca5370fff11ca63fc31f4a3c8c9c83b4ed4a7e Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Mon, 27 Apr 2026 15:07:26 +0900 Subject: [PATCH 09/99] =?UTF-8?q?refactor:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4=20Compose=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20#497?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NotificationScreen + NotificationSwitchRow Compose 작성 - NotificationFragment 를 Fragment + ComposeView 로 전환 - collectAsStateWithLifecycle 로 ViewModel 상태 관찰 - fragment_notification.xml, notification_switch style, style_toggle_track/thumb drawable 정리 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../profile/setting/NotificationFragment.kt | 89 ++++++-------- .../profile/setting/NotificationScreen.kt | 116 ++++++++++++++++++ .../main/res/drawable/style_toggle_thumb.xml | 7 -- .../main/res/drawable/style_toggle_track.xml | 17 --- .../main/res/layout/fragment_notification.xml | 85 ------------- .../main/res/navigation/profile_nav_graph.xml | 3 +- .../profile/src/main/res/values/styles.xml | 7 -- 7 files changed, 155 insertions(+), 169 deletions(-) create mode 100644 feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt delete mode 100644 feature/profile/src/main/res/drawable/style_toggle_thumb.xml delete mode 100644 feature/profile/src/main/res/drawable/style_toggle_track.xml delete mode 100644 feature/profile/src/main/res/layout/fragment_notification.xml diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt index e16f3d9c..843fe301 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt @@ -4,78 +4,71 @@ import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.os.Build +import android.os.Bundle import android.provider.Settings +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController import com.project200.domain.model.NotificationType -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentNotificationBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import timber.log.Timber @AndroidEntryPoint -class NotificationFragment : BindingFragment(R.layout.fragment_notification) { +class NotificationFragment : Fragment() { private val viewModel: NotificationViewModel by viewModels() - // 알림 권한 요청을 위한 ActivityResultLauncher private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> viewModel.updateNotiStateByPermission(isGranted) if (!isGranted) { Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() - binding.exerciseNotiSwitch.isChecked = false - binding.chattingNotiSwitch.isChecked = false } } - override fun getViewBinding(view: View): FragmentNotificationBinding { - return FragmentNotificationBinding.bind(view) - } - - override fun onResume() { - super.onResume() - // 화면에 다시 돌아왔을 때 현재 알림 권한 상태를 체크하여 UI를 업데이트 - checkNotificationPermission() - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val states by viewModel.notificationStates.collectAsStateWithLifecycle() + val isExerciseOn = states.find { it.type == NotificationType.WORKOUT_REMINDER }?.enabled ?: false + val isChattingOn = states.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled ?: false - override fun setupViews() { - binding.backBtnIv.setOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } - binding.exerciseNotiSwitch.setOnClickListener { - viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, binding.exerciseNotiSwitch.isChecked) + NotificationScreen( + isExerciseOn = isExerciseOn, + isChattingOn = isChattingOn, + onNavigateBack = { findNavController().popBackStack() }, + onExerciseToggle = { viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, it) }, + onChattingToggle = { viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, it) }, + ) + } } - binding.chattingNotiSwitch.setOnClickListener { - viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, binding.chattingNotiSwitch.isChecked) - } - } + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) - override fun setupObservers() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - viewModel.notificationStates.collect { states -> - // 운동 알림 스위치 상태 설정 - val isExerciseOn = states.find { it.type == NotificationType.WORKOUT_REMINDER }?.enabled ?: false - if (binding.exerciseNotiSwitch.isChecked != isExerciseOn) { - binding.exerciseNotiSwitch.isChecked = isExerciseOn - } - - // 채팅 알림 스위치 상태 설정 - val isChatOn = states.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled ?: false - if (binding.chattingNotiSwitch.isChecked != isChatOn) { - binding.chattingNotiSwitch.isChecked = isChatOn - } - } - } - launch { viewModel.permissionRequestTrigger.collect { needsRequest -> if (needsRequest) { @@ -96,7 +89,11 @@ class NotificationFragment : BindingFragment(R.layo } } - // 현재 알림 권한 상태를 확인 + override fun onResume() { + super.onResume() + checkNotificationPermission() + } + private fun checkNotificationPermission() { val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -107,41 +104,31 @@ class NotificationFragment : BindingFragment(R.layo } else { true } - // 확인된 권한 상태를 ViewModel에 전달하여 초기 로직을 수행하도록 합니다. viewModel.updateNotiStateByPermission(isGranted) if (!isGranted) { Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() - binding.exerciseNotiSwitch.isChecked = false - binding.chattingNotiSwitch.isChecked = false } } - // 알림 권한 요청 private fun requestNotificationPermission() { - // Android 13 (Tiramisu) 이상에서만 런타임 권한 요청 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { when { ContextCompat.checkSelfPermission( requireContext(), Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED -> { - // 이미 권한이 있는 경우 Timber.tag("NotificationFragment").d("Notification permission already granted.") } shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { openAppSettings() } else -> { - // 처음 권한을 요청하는 경우 requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } - } else { - // Android 13 미만 버전에서는 별도의 런타임 권한이 필요 없음 } } - // 앱의 알림 설정 화면으로 이동 private fun openAppSettings() { val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt new file mode 100644 index 00000000..56b5d2e6 --- /dev/null +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt @@ -0,0 +1,116 @@ +package com.project200.undabang.profile.setting + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentRegular +import com.project200.undabang.feature.profile.R + +@Composable +fun NotificationScreen( + isExerciseOn: Boolean, + isChattingOn: Boolean, + onNavigateBack: () -> Unit, + onExerciseToggle: (Boolean) -> Unit, + onChattingToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(ColorWhite300), + ) { + UndabangTopBar( + title = stringResource(R.string.notification), + onNavigationClick = onNavigateBack, + ) + + NotificationSwitchRow( + labelRes = R.string.exercise_notification, + checked = isExerciseOn, + onCheckedChange = onExerciseToggle, + ) + NotificationSwitchRow( + labelRes = R.string.chatting_notification, + checked = isChattingOn, + onCheckedChange = onChattingToggle, + ) + } +} + +@Composable +private fun NotificationSwitchRow( + @StringRes labelRes: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(id = labelRes), + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.contentRegular, + color = ColorBlack, + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = ColorWhite300, + checkedTrackColor = ColorMain, + checkedBorderColor = ColorMain, + uncheckedThumbColor = ColorWhite300, + uncheckedTrackColor = ColorGray200, + uncheckedBorderColor = ColorGray200, + ), + ) + } + HorizontalDivider(thickness = 0.3.dp, color = ColorBlack) + } +} + +@Preview(showBackground = true) +@Composable +private fun NotificationScreenPreview() { + AppTheme { + NotificationScreen( + isExerciseOn = true, + isChattingOn = false, + onNavigateBack = {}, + onExerciseToggle = {}, + onChattingToggle = {}, + ) + } +} diff --git a/feature/profile/src/main/res/drawable/style_toggle_thumb.xml b/feature/profile/src/main/res/drawable/style_toggle_thumb.xml deleted file mode 100644 index 5f06e683..00000000 --- a/feature/profile/src/main/res/drawable/style_toggle_thumb.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/drawable/style_toggle_track.xml b/feature/profile/src/main/res/drawable/style_toggle_track.xml deleted file mode 100644 index b22b4543..00000000 --- a/feature/profile/src/main/res/drawable/style_toggle_track.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/fragment_notification.xml b/feature/profile/src/main/res/layout/fragment_notification.xml deleted file mode 100644 index a1e6520b..00000000 --- a/feature/profile/src/main/res/layout/fragment_notification.xml +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/navigation/profile_nav_graph.xml b/feature/profile/src/main/res/navigation/profile_nav_graph.xml index 5441ac65..c461e215 100644 --- a/feature/profile/src/main/res/navigation/profile_nav_graph.xml +++ b/feature/profile/src/main/res/navigation/profile_nav_graph.xml @@ -56,8 +56,7 @@ + android:label="notification" /> 1.2 - -