From e573f864ef99bb766c49134cb82235ab627bccce Mon Sep 17 00:00:00 2001 From: cnsvkf Date: Sat, 30 May 2026 21:06:26 +0900 Subject: [PATCH 1/5] =?UTF-8?q?build(gradle):=20Hilt=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=B1=20=EA=B8=B0=EB=B0=98=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 10 ++++++++++ .../java/com/example/it_da/ItdaApplication.kt | 2 ++ .../main/java/com/example/it_da/MainActivity.kt | 2 ++ build.gradle.kts | 3 +++ gradle/libs.versions.toml | 16 ++++++++++++++++ 5 files changed, 33 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c605554..0bb5117 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,9 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.hilt.android) + alias(libs.plugins.ksp) } val localProperties = Properties().apply { @@ -72,6 +75,7 @@ android { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) @@ -85,6 +89,12 @@ dependencies { implementation(libs.androidx.credentials.play.services.auth) implementation(libs.googleid) implementation(libs.kakao.user) + implementation(libs.kotlinx.serialization.json) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) + ksp(libs.hilt.compiler) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/example/it_da/ItdaApplication.kt b/app/src/main/java/com/example/it_da/ItdaApplication.kt index 51e0c50..314fc27 100644 --- a/app/src/main/java/com/example/it_da/ItdaApplication.kt +++ b/app/src/main/java/com/example/it_da/ItdaApplication.kt @@ -2,7 +2,9 @@ package com.example.it_da import android.app.Application import com.kakao.sdk.common.KakaoSdk +import dagger.hilt.android.HiltAndroidApp +@HiltAndroidApp class ItdaApplication : Application() { // Initializes Kakao SDK once when the app process starts if a native app key is configured. override fun onCreate() { diff --git a/app/src/main/java/com/example/it_da/MainActivity.kt b/app/src/main/java/com/example/it_da/MainActivity.kt index 0f88b5a..10e8d8b 100644 --- a/app/src/main/java/com/example/it_da/MainActivity.kt +++ b/app/src/main/java/com/example/it_da/MainActivity.kt @@ -6,7 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import com.example.it_da.ui.ItdaApp import com.example.it_da.ui.theme.ITDATheme +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/build.gradle.kts b/build.gradle.kts index b546c74..40c0434 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.ksp) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f4a166..3eae6da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,12 +7,18 @@ espressoCore = "3.5.1" lifecycleRuntimeKtx = "2.6.1" navigationCompose = "2.9.7" activityCompose = "1.8.0" +dataStore = "1.2.1" kotlin = "2.2.10" +kotlinxSerialization = "1.9.0" composeBom = "2026.02.01" credentials = "1.6.0" googleId = "1.2.0" kakao = "2.23.4" coroutines = "1.10.2" +retrofit = "3.0.0" +hilt = "2.59.2" +androidxHilt = "1.3.0" +ksp = "2.3.9" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,6 +30,7 @@ androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifec androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStore" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } @@ -37,7 +44,16 @@ androidx-credentials-play-services-auth = { group = "androidx.credentials", name googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleId" } kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } From 5a8f3e6afaab4b41e08177598d7416172a141751 Mon Sep 17 00:00:00 2001 From: cnsvkf Date: Sat, 30 May 2026 21:06:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor(common):=20=EA=B3=B5=ED=86=B5=20UI?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ItdaBottomNavigationBar.kt | 174 ++++++++++++++++++ .../it_da/ui/commonComponent/ItdaCard.kt | 23 +++ .../ItdaCardDefaults.kt | 2 +- .../ItdaDropdownTextField.kt} | 24 +-- .../ui/commonComponent/ItdaImageButton.kt | 36 ++++ .../ui/commonComponent/ItdaLayoutDefaults.kt | 23 +++ .../ItdaOutlinedBadge.kt | 11 +- .../ItdaOutlinedTextField.kt} | 51 +++-- .../ItdaPrimaryButton.kt} | 24 +-- .../ItdaSectionHeader.kt | 18 +- .../ItdaTopBar.kt} | 44 +++-- .../ItdaUnderlinedTextButton.kt | 14 +- .../section/HomeNotificationSection.kt | 25 +-- .../section/HomeProfileSummarySection.kt | 53 +++--- .../section/ParticipatingProjectSection.kt | 18 +- .../section/RecommendedProjectSection.kt | 16 +- .../ui/component/card/HomeNotificationCard.kt | 86 --------- .../card/ParticipatingProjectCard.kt | 131 ------------- .../component/card/RecommendedProjectCard.kt | 157 ---------------- .../ui/screen/login/component/LoginButton.kt | 2 +- .../screen/login/component/LoginInputGroup.kt | 4 +- .../login/component/LoginIntroTextGroup.kt | 4 +- .../component/LoginUnderlineTextField.kt | 7 +- .../login/component/SocialLoginButtonRow.kt | 4 +- .../signup/screen/SignUpAccountScreen.kt | 52 +++--- .../screen/SignUpAdditionalInfoScreen.kt | 42 +++-- .../java/com/example/it_da/ui/theme/Color.kt | 1 + .../java/com/example/it_da/ui/theme/Font.kt | 8 +- .../java/com/example/it_da/ui/theme/Type.kt | 82 +++++++-- app/src/main/res/drawable/backbutton.png | Bin 0 -> 269 bytes app/src/main/res/drawable/check.png | Bin 0 -> 426 bytes app/src/main/res/drawable/not_check.png | Bin 0 -> 665 bytes app/src/main/res/values/strings.xml | 66 ++++++- app/src/main/res/xml/backup_rules.xml | 3 +- .../main/res/xml/data_extraction_rules.xml | 8 +- 35 files changed, 592 insertions(+), 621 deletions(-) create mode 100644 app/src/main/java/com/example/it_da/ui/commonComponent/ItdaBottomNavigationBar.kt create mode 100644 app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCard.kt rename app/src/main/java/com/example/it_da/ui/{component => commonComponent}/ItdaCardDefaults.kt (90%) rename app/src/main/java/com/example/it_da/ui/{screen/signup/component/SignUpDropdownTextField.kt => commonComponent/ItdaDropdownTextField.kt} (85%) create mode 100644 app/src/main/java/com/example/it_da/ui/commonComponent/ItdaImageButton.kt create mode 100644 app/src/main/java/com/example/it_da/ui/commonComponent/ItdaLayoutDefaults.kt rename app/src/main/java/com/example/it_da/ui/{component => commonComponent}/ItdaOutlinedBadge.kt (77%) rename app/src/main/java/com/example/it_da/ui/{screen/signup/component/SignUpOutlinedTextField.kt => commonComponent/ItdaOutlinedTextField.kt} (73%) rename app/src/main/java/com/example/it_da/ui/{screen/signup/component/SignUpPrimaryButton.kt => commonComponent/ItdaPrimaryButton.kt} (72%) rename app/src/main/java/com/example/it_da/ui/{component => commonComponent}/ItdaSectionHeader.kt (73%) rename app/src/main/java/com/example/it_da/ui/{screen/signup/component/SignUpTopBar.kt => commonComponent/ItdaTopBar.kt} (52%) rename app/src/main/java/com/example/it_da/ui/{component => commonComponent}/ItdaUnderlinedTextButton.kt (73%) rename app/src/main/java/com/example/it_da/ui/{component => commonComponent}/section/HomeNotificationSection.kt (59%) rename app/src/main/java/com/example/it_da/ui/{component => commonComponent}/section/HomeProfileSummarySection.kt (71%) rename app/src/main/java/com/example/it_da/ui/{component => commonComponent}/section/ParticipatingProjectSection.kt (63%) rename app/src/main/java/com/example/it_da/ui/{component => commonComponent}/section/RecommendedProjectSection.kt (66%) delete mode 100644 app/src/main/java/com/example/it_da/ui/component/card/HomeNotificationCard.kt delete mode 100644 app/src/main/java/com/example/it_da/ui/component/card/ParticipatingProjectCard.kt delete mode 100644 app/src/main/java/com/example/it_da/ui/component/card/RecommendedProjectCard.kt create mode 100644 app/src/main/res/drawable/backbutton.png create mode 100644 app/src/main/res/drawable/check.png create mode 100644 app/src/main/res/drawable/not_check.png diff --git a/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaBottomNavigationBar.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaBottomNavigationBar.kt new file mode 100644 index 0000000..6f6a1f3 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaBottomNavigationBar.kt @@ -0,0 +1,174 @@ +package com.example.it_da.ui.commonComponent + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.example.it_da.R +import com.example.it_da.ui.theme.ItdaHomeDividerGray +import com.example.it_da.ui.theme.ItdaSecondaryTextColor +import com.example.it_da.ui.theme.ItdaWhite + +private val ItdaBottomNavigationBarHeight = 52.5.dp +private val ItdaBottomNavigationContainerHeight = 62.dp +private val ItdaBottomNavigationItemHeight = 48.dp +private val ItdaCenterNavigationClickSize = 52.dp +private val ItdaBottomNavigationIconLabelSpacing = 2.dp + +// Shows the fixed bottom navigation bar and exposes each tab as a callback. +@Composable +fun ItdaBottomNavigationBar( + onHomeClick: () -> Unit, + onExploreClick: () -> Unit, + onCreateProjectClick: () -> Unit, + onNotificationClick: () -> Unit, + onProfileClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(ItdaBottomNavigationContainerHeight) + ) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(ItdaBottomNavigationBarHeight), + color = ItdaWhite, + border = BorderStroke(1.dp, ItdaHomeDividerGray) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + ItdaBottomNavigationItem( + iconResId = R.drawable.bottom_bar_home, + label = stringResource(id = R.string.home_bottom_tab_home), + contentDescription = stringResource(id = R.string.home_bottom_tab_home), + iconSize = 25.dp, + onClick = onHomeClick + ) + + ItdaBottomNavigationItem( + iconResId = R.drawable.bottom_bar_research, + label = stringResource(id = R.string.home_bottom_tab_explore), + contentDescription = stringResource(id = R.string.home_bottom_tab_explore), + iconSize = 25.dp, + onClick = onExploreClick + ) + + Spacer( + modifier = Modifier + .width(ItdaCenterNavigationClickSize) + .height(ItdaBottomNavigationItemHeight) + ) + + ItdaBottomNavigationItem( + iconResId = R.drawable.bottom_bar_bell, + label = stringResource(id = R.string.home_bottom_tab_notification), + contentDescription = stringResource( + id = R.string.home_bottom_tab_notification + ), + iconSize = 25.dp, + onClick = onNotificationClick + ) + + ItdaBottomNavigationItem( + iconResId = R.drawable.bottom_bar_profile, + label = stringResource(id = R.string.home_bottom_tab_profile), + contentDescription = stringResource(id = R.string.home_bottom_tab_profile), + iconSize = 25.dp, + onClick = onProfileClick + ) + } + } + + ItdaCenterNavigationButton( + onClick = onCreateProjectClick, + modifier = Modifier.align(Alignment.TopCenter) + ) + } +} + +// Shows a normal labeled bottom navigation item. +@Composable +private fun ItdaBottomNavigationItem( + @DrawableRes iconResId: Int, + label: String, + contentDescription: String, + iconSize: Dp, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .width(52.dp) + .height(ItdaBottomNavigationItemHeight) + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = iconResId), + contentDescription = contentDescription, + modifier = Modifier.size(iconSize) + ) + + Spacer(modifier = Modifier.height(ItdaBottomNavigationIconLabelSpacing)) + + Text( + text = label, + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.labelSmall + ) + } +} + +// Shows the center add-project action as the prominent middle bottom button. +@Composable +private fun ItdaCenterNavigationButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(ItdaCenterNavigationClickSize) + .zIndex(1f) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.bottom_bar_center), + contentDescription = stringResource( + id = R.string.home_bottom_create_project_description + ), + modifier = Modifier.size(45.dp) + ) + } +} diff --git a/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCard.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCard.kt new file mode 100644 index 0000000..6400ed8 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCard.kt @@ -0,0 +1,23 @@ +package com.example.it_da.ui.commonComponent + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.it_da.ui.theme.ItdaWhite + +// Provides the shared card appearance while leaving all content and interactions to its caller. +@Composable +fun ItdaCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(8.dp), + color = ItdaWhite, + border = ItdaCardDefaults.outlinedBorder(), + content = content + ) +} diff --git a/app/src/main/java/com/example/it_da/ui/component/ItdaCardDefaults.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCardDefaults.kt similarity index 90% rename from app/src/main/java/com/example/it_da/ui/component/ItdaCardDefaults.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCardDefaults.kt index 0882cc2..b24b81e 100644 --- a/app/src/main/java/com/example/it_da/ui/component/ItdaCardDefaults.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaCardDefaults.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.BorderStroke import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpDropdownTextField.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaDropdownTextField.kt similarity index 85% rename from app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpDropdownTextField.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaDropdownTextField.kt index f37a043..247e058 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpDropdownTextField.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaDropdownTextField.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.screen.signup.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource @@ -23,21 +23,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.example.it_da.R -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaInputBorderGray import com.example.it_da.ui.theme.ItdaPlaceholderTextColor import com.example.it_da.ui.theme.ItdaSecondaryTextColor -private val SignUpDropdownInputTextWeight = FontWeight(600) - // Draws a rounded input with a button arrow reserved for a later dropdown screen. @Composable -fun SignUpDropdownTextField( +fun ItdaDropdownTextField( label: String, value: String, onValueChange: (String) -> Unit, @@ -47,17 +42,12 @@ fun SignUpDropdownTextField( ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val inputTextStyle = TextStyle( - fontFamily = DotSans, - fontWeight = SignUpDropdownInputTextWeight, - fontSize = 15.sp, - lineHeight = 15.sp, - letterSpacing = 0.sp, + val inputTextStyle = MaterialTheme.typography.labelLarge.copy( color = MaterialTheme.colorScheme.onBackground ) Column(modifier = modifier.fillMaxWidth()) { - SignUpFieldLabel(text = label) + ItdaFieldLabel(text = label) Row( modifier = Modifier @@ -102,7 +92,9 @@ fun SignUpDropdownTextField( ) { Icon( painter = painterResource(id = R.drawable.ic_down_arrow), - contentDescription = "open options", + contentDescription = stringResource( + id = R.string.common_dropdown_open_description + ), tint = ItdaSecondaryTextColor, modifier = Modifier.size(24.dp) ) diff --git a/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaImageButton.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaImageButton.kt new file mode 100644 index 0000000..50fef07 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaImageButton.kt @@ -0,0 +1,36 @@ +package com.example.it_da.ui.commonComponent + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource + +// Displays a drawable inside a reusable clickable image area. +@Composable +fun ItdaImageButton( + @DrawableRes imageResId: Int, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier, + shape: Shape +) { + Box( + modifier = modifier + .clip(shape) + .clickable(onClick = onClick) + ) { + Image( + painter = painterResource(id = imageResId), + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + modifier = imageModifier + ) + } +} diff --git a/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaLayoutDefaults.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaLayoutDefaults.kt new file mode 100644 index 0000000..efbe393 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaLayoutDefaults.kt @@ -0,0 +1,23 @@ +package com.example.it_da.ui.commonComponent + +import androidx.compose.ui.unit.dp + +object ItdaLayoutDefaults { + // Keeps scrollable screen content visible above the fixed bottom navigation bar. + val BottomNavigationContentPadding = 80.dp + + // Provides the standard horizontal padding for form-style screens. + val FormHorizontalPadding = 30.dp + + // Provides the short vertical gap used between closely related form fields. + val ShortVerticalSpacing = 15.dp + + // Provides the long vertical gap used between separated screen sections. + val LongVerticalSpacing = 30.dp + + // Separates a section heading from the content displayed below it. + val SectionHeaderContentSpacing = 13.dp + + // Separates project cards displayed together inside a project section. + val ProjectCardSpacing = 15.dp +} diff --git a/app/src/main/java/com/example/it_da/ui/component/ItdaOutlinedBadge.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedBadge.kt similarity index 77% rename from app/src/main/java/com/example/it_da/ui/component/ItdaOutlinedBadge.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedBadge.kt index ac4b725..af3d220 100644 --- a/app/src/main/java/com/example/it_da/ui/component/ItdaOutlinedBadge.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedBadge.kt @@ -1,16 +1,14 @@ -package com.example.it_da.ui.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaGuideGray import com.example.it_da.ui.theme.ItdaSecondaryTextColor @@ -29,10 +27,7 @@ fun ItdaOutlinedBadge( Text( text = text, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 12.sp, + style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) ) } diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpOutlinedTextField.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedTextField.kt similarity index 73% rename from app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpOutlinedTextField.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedTextField.kt index 3f36f9a..a04ce02 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpOutlinedTextField.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaOutlinedTextField.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.screen.signup.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource @@ -18,60 +18,55 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight 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.example.it_da.ui.theme.ItdaPlaceholderTextColor import com.example.it_da.ui.theme.ItdaInputBorderGray -import com.example.it_da.ui.theme.DotSans - -private val SignUpFieldLabelWeight = FontWeight.Medium -private val SignUpInputTextWeight = FontWeight(600) +import com.example.it_da.ui.theme.ItdaPlaceholderTextColor +import com.example.it_da.ui.theme.ItdaSectionTextColor // Draws a labeled rounded input that hides its example text while focused or filled. @Composable -fun SignUpOutlinedTextField( +fun ItdaOutlinedTextField( label: String, value: String, onValueChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier, - visualTransformation: VisualTransformation = VisualTransformation.None + visualTransformation: VisualTransformation = VisualTransformation.None, + inputHeight: Dp = 40.dp, + singleLine: Boolean = true ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val inputTextStyle = TextStyle( - fontFamily = DotSans, - fontWeight = SignUpInputTextWeight, - fontSize = 15.sp, - lineHeight = 15.sp, - letterSpacing = 0.sp, + val inputTextStyle = MaterialTheme.typography.labelLarge.copy( color = MaterialTheme.colorScheme.onBackground ) Column(modifier = modifier.fillMaxWidth()) { - SignUpFieldLabel(text = label) + ItdaFieldLabel(text = label) Box( modifier = Modifier .fillMaxWidth() - .height(40.dp) + .height(inputHeight) .border( width = 1.2.dp, color = ItdaInputBorderGray, shape = RoundedCornerShape(10.dp) ) - .padding(horizontal = 11.dp), - contentAlignment = Alignment.CenterStart + .padding( + horizontal = 11.dp, + vertical = if (singleLine) 0.dp else 11.dp + ), + contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart ) { BasicTextField( value = value, onValueChange = onValueChange, modifier = Modifier.fillMaxWidth(), textStyle = inputTextStyle, - singleLine = true, + singleLine = singleLine, cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground), visualTransformation = visualTransformation, interactionSource = interactionSource, @@ -90,20 +85,16 @@ fun SignUpOutlinedTextField( } } -// Draws the field label shared by all sign-up form inputs. +// Draws the field label shared by reusable form inputs. @Composable -fun SignUpFieldLabel( +fun ItdaFieldLabel( text: String, modifier: Modifier = Modifier ) { Text( text = text, modifier = modifier.padding(bottom = 12.dp), - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = SignUpFieldLabelWeight, - fontSize = 16.sp, - lineHeight = 16.sp - ) + color = ItdaSectionTextColor, + style = MaterialTheme.typography.titleMedium ) } diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpPrimaryButton.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaPrimaryButton.kt similarity index 72% rename from app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpPrimaryButton.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaPrimaryButton.kt index 3bc517c..fa10f04 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpPrimaryButton.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaPrimaryButton.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.screen.signup.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box @@ -13,23 +13,23 @@ 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.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.example.it_da.R import com.example.it_da.ui.theme.ItdaLoginButtonDisabledBlack import com.example.it_da.ui.theme.ItdaButtonTextColor -private val SignUpPrimaryButtonTextWeight = FontWeight(600) - -// Shows the sign-up primary action and lets Button enforce the enabled click rule. +// Shows a primary action and lets Button enforce the enabled click rule. @Composable -fun SignUpPrimaryButton( +fun ItdaPrimaryButton( enabled: Boolean, onClick: () -> Unit, - text: String = "다음으로", + text: String? = null, containerColor: Color = MaterialTheme.colorScheme.onBackground, modifier: Modifier = Modifier ) { + val buttonText = text ?: stringResource(id = R.string.common_next) + Button( onClick = onClick, enabled = enabled, @@ -50,12 +50,8 @@ fun SignUpPrimaryButton( ) { Box(contentAlignment = Alignment.Center) { Text( - text = text, - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = SignUpPrimaryButtonTextWeight, - fontSize = 20.sp, - lineHeight = 20.sp - ) + text = buttonText, + style = MaterialTheme.typography.displaySmall ) } } diff --git a/app/src/main/java/com/example/it_da/ui/component/ItdaSectionHeader.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaSectionHeader.kt similarity index 73% rename from app/src/main/java/com/example/it_da/ui/component/ItdaSectionHeader.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaSectionHeader.kt index b4c3ff8..4db807a 100644 --- a/app/src/main/java/com/example/it_da/ui/component/ItdaSectionHeader.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaSectionHeader.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -7,15 +7,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaPrimaryTextColor import com.example.it_da.ui.theme.ItdaSecondaryTextColor +private val ItdaSectionTitleDescriptionSpacing = 12.dp + // Displays a reusable section title and optional guide text with the app typography. @Composable fun ItdaSectionHeader( @@ -23,14 +23,13 @@ fun ItdaSectionHeader( modifier: Modifier = Modifier, description: String? = null, titleFontSize: TextUnit = 21.sp, - titleFontWeight: FontWeight = FontWeight.Medium + titleFontWeight: FontWeight = FontWeight.SemiBold ) { Column(modifier = modifier) { Text( text = title, color = ItdaPrimaryTextColor, style = MaterialTheme.typography.titleLarge.copy( - fontFamily = DotSans, fontWeight = titleFontWeight, fontSize = titleFontSize, lineHeight = titleFontSize @@ -38,17 +37,12 @@ fun ItdaSectionHeader( ) if (description != null) { - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(ItdaSectionTitleDescriptionSpacing)) Text( text = description, color = ItdaSecondaryTextColor, - style = TextStyle( - fontFamily = DotSans, - fontWeight = FontWeight.Medium, - fontSize = 13.sp, - lineHeight = 13.sp - ) + style = MaterialTheme.typography.titleSmall ) } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpTopBar.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaTopBar.kt similarity index 52% rename from app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpTopBar.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaTopBar.kt index 90c34e1..46c906b 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/component/SignUpTopBar.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaTopBar.kt @@ -1,10 +1,12 @@ -package com.example.it_da.ui.screen.signup.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box +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.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -12,38 +14,48 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.example.it_da.R import com.example.it_da.ui.theme.ItdaTopBarTitleTextColor -private val SignUpTopBarHeight = 72.dp -private val SignUpTopTitleTopPadding = 22.dp -private val SignUpTopTitleWeight = FontWeight(700) +private val ItdaTopBarHeight = 72.dp +private val ItdaTopTitleTopPadding = 22.dp +private val ItdaTopBackButtonSize = 30.dp -// Draws the sign-up title area and the design line asset below it. +// Draws the shared title area and the design line asset below it. @Composable -fun SignUpTopBar( +fun ItdaTopBar( title: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onBackClick: (() -> Unit)? = null, + backContentDescription: String? = null ) { Box( modifier = modifier .fillMaxWidth() - .height(SignUpTopBarHeight) + .height(ItdaTopBarHeight) ) { + if (onBackClick != null) { + ItdaImageButton( + imageResId = R.drawable.backbutton, + contentDescription = backContentDescription, + onClick = onBackClick, + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 25.dp, top = 25.dp) + .size(ItdaTopBackButtonSize), + imageModifier = Modifier.fillMaxSize(), + shape = MaterialTheme.shapes.small + ) + } + Text( text = title, modifier = Modifier .align(Alignment.TopCenter) - .padding(top = SignUpTopTitleTopPadding), + .padding(top = ItdaTopTitleTopPadding), color = ItdaTopBarTitleTextColor, - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = SignUpTopTitleWeight, - fontSize = 23.sp, - lineHeight = 27.sp - ) + style = MaterialTheme.typography.displayLarge ) Image( diff --git a/app/src/main/java/com/example/it_da/ui/component/ItdaUnderlinedTextButton.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaUnderlinedTextButton.kt similarity index 73% rename from app/src/main/java/com/example/it_da/ui/component/ItdaUnderlinedTextButton.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/ItdaUnderlinedTextButton.kt index 29e9395..de5efe8 100644 --- a/app/src/main/java/com/example/it_da/ui/component/ItdaUnderlinedTextButton.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/ItdaUnderlinedTextButton.kt @@ -1,18 +1,16 @@ -package com.example.it_da.ui.component +package com.example.it_da.ui.commonComponent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaSecondaryTextColor // Shows a compact underlined text action for secondary navigation inside cards or sections. @@ -31,11 +29,9 @@ fun ItdaUnderlinedTextButton( Text( text = text, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 10.sp, - lineHeight = 10.sp, - textDecoration = TextDecoration.Underline + style = MaterialTheme.typography.labelSmall.copy( + textDecoration = TextDecoration.Underline + ) ) } } diff --git a/app/src/main/java/com/example/it_da/ui/component/section/HomeNotificationSection.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeNotificationSection.kt similarity index 59% rename from app/src/main/java/com/example/it_da/ui/component/section/HomeNotificationSection.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeNotificationSection.kt index 4536dba..6ddafc9 100644 --- a/app/src/main/java/com/example/it_da/ui/component/section/HomeNotificationSection.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeNotificationSection.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.section +package com.example.it_da.ui.commonComponent.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,13 +7,17 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.example.it_da.ui.component.ItdaSectionHeader -import com.example.it_da.ui.component.ItdaUnderlinedTextButton -import com.example.it_da.ui.component.card.HomeNotificationCard +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaSectionHeader +import com.example.it_da.ui.commonComponent.ItdaUnderlinedTextButton +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.screen.home.component.card.HomeNotificationCard import com.example.it_da.ui.screen.home.state.HomeNotificationUiModel +private val HomeNotificationCardSpacing = 13.dp + // Shows the notification summary section and a separate all-notifications action. @Composable fun HomeNotificationSection( @@ -23,15 +27,12 @@ fun HomeNotificationSection( modifier: Modifier = Modifier ) { Column(modifier = modifier.fillMaxWidth()) { - ItdaSectionHeader( - title = "알림 요약ㆍ확인", - titleFontWeight = FontWeight.Medium - ) + ItdaSectionHeader(title = stringResource(id = R.string.home_notification_section_title)) - Spacer(modifier = Modifier.height(13.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.SectionHeaderContentSpacing)) Column( - verticalArrangement = Arrangement.spacedBy(13.dp), + verticalArrangement = Arrangement.spacedBy(HomeNotificationCardSpacing), modifier = Modifier.fillMaxWidth() ) { notifications.forEach { notification -> @@ -42,7 +43,7 @@ fun HomeNotificationSection( } ItdaUnderlinedTextButton( - text = "모든 알림 보기", + text = stringResource(id = R.string.home_notification_view_all), onClick = onViewAllClick ) } diff --git a/app/src/main/java/com/example/it_da/ui/component/section/HomeProfileSummarySection.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeProfileSummarySection.kt similarity index 71% rename from app/src/main/java/com/example/it_da/ui/component/section/HomeProfileSummarySection.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeProfileSummarySection.kt index b95f1bb..cdc0f08 100644 --- a/app/src/main/java/com/example/it_da/ui/component/section/HomeProfileSummarySection.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/section/HomeProfileSummarySection.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.section +package com.example.it_da.ui.commonComponent.section import androidx.annotation.DrawableRes import androidx.compose.foundation.Image @@ -19,15 +19,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.it_da.ui.component.ItdaCardDefaults +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaCardDefaults import com.example.it_da.ui.screen.home.state.HomeProjectCountUiModel -import com.example.it_da.ui.theme.DotSans import com.example.it_da.ui.theme.ItdaPrimaryTextColor import com.example.it_da.ui.theme.ItdaSecondaryTextColor +private val HomeProfileImageGreetingSpacing = 13.dp +private val HomeProfileGreetingDescriptionSpacing = 5.dp +private val HomeProfileCountCardSpacing = 18.dp +private val HomeProjectCountLabelValueSpacing = 17.dp + // Shows the user greeting and the project status count summary. @Composable fun HomeProfileSummarySection( @@ -44,36 +49,32 @@ fun HomeProfileSummarySection( ) { Image( painter = painterResource(id = profileImageResId), - contentDescription = "프로필 이미지", + contentDescription = stringResource(id = R.string.home_profile_image_description), modifier = Modifier.size(58.dp) ) - Spacer(modifier = Modifier.width(13.dp)) + Spacer(modifier = Modifier.width(HomeProfileImageGreetingSpacing)) Column { Text( - text = "안녕하세요, ${userName}님 👋", + text = stringResource(id = R.string.home_profile_greeting, userName), color = ItdaPrimaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - lineHeight = 18.sp + style = MaterialTheme.typography.headlineLarge ) - Spacer(modifier = Modifier.height(5.dp)) + Spacer(modifier = Modifier.height(HomeProfileGreetingDescriptionSpacing)) Text( text = greetingDescription, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 15.sp + style = MaterialTheme.typography.bodySmall.copy( + lineHeight = 15.sp + ) ) } } - Spacer(modifier = Modifier.height(18.dp)) + Spacer(modifier = Modifier.height(HomeProfileCountCardSpacing)) HomeProjectCountCard(projectCount = projectCount) } @@ -105,15 +106,15 @@ private fun HomeProjectCountCard( verticalAlignment = Alignment.CenterVertically ) { HomeProjectCountItem( - label = "지원 중", + label = stringResource(id = R.string.home_project_count_applying), count = projectCount.applyingCount ) HomeProjectCountItem( - label = "참여 중", + label = stringResource(id = R.string.home_project_count_participating), count = projectCount.participatingCount ) HomeProjectCountItem( - label = "완료", + label = stringResource(id = R.string.home_project_count_completed), count = projectCount.completedCount ) } @@ -134,21 +135,15 @@ private fun HomeProjectCountItem( Text( text = label, color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 13.sp, - lineHeight = 13.sp + style = MaterialTheme.typography.bodyMedium ) - Spacer(modifier = Modifier.height(17.dp)) + Spacer(modifier = Modifier.height(HomeProjectCountLabelValueSpacing)) Text( text = count.toString(), color = ItdaPrimaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - lineHeight = 18.sp + style = MaterialTheme.typography.headlineLarge ) } } diff --git a/app/src/main/java/com/example/it_da/ui/component/section/ParticipatingProjectSection.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/section/ParticipatingProjectSection.kt similarity index 63% rename from app/src/main/java/com/example/it_da/ui/component/section/ParticipatingProjectSection.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/section/ParticipatingProjectSection.kt index 39be3c1..d34b663 100644 --- a/app/src/main/java/com/example/it_da/ui/component/section/ParticipatingProjectSection.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/section/ParticipatingProjectSection.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.section +package com.example.it_da.ui.commonComponent.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.example.it_da.ui.component.ItdaSectionHeader -import com.example.it_da.ui.component.card.ParticipatingProjectCard +import androidx.compose.ui.res.stringResource +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.commonComponent.ItdaSectionHeader +import com.example.it_da.ui.screen.home.component.card.ParticipatingProjectCard import com.example.it_da.ui.screen.home.state.ParticipatingProjectUiModel // Shows the user's participating projects with a card type separate from recommendations. @@ -21,12 +23,14 @@ fun ParticipatingProjectSection( modifier: Modifier = Modifier ) { Column(modifier = modifier.fillMaxWidth()) { - ItdaSectionHeader(title = "참여 중인 프로젝트") + ItdaSectionHeader( + title = stringResource(id = R.string.home_participating_project_section_title) + ) - Spacer(modifier = Modifier.height(13.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.SectionHeaderContentSpacing)) Column( - verticalArrangement = Arrangement.spacedBy(15.dp), + verticalArrangement = Arrangement.spacedBy(ItdaLayoutDefaults.ProjectCardSpacing), modifier = Modifier.fillMaxWidth() ) { projects.forEach { project -> diff --git a/app/src/main/java/com/example/it_da/ui/component/section/RecommendedProjectSection.kt b/app/src/main/java/com/example/it_da/ui/commonComponent/section/RecommendedProjectSection.kt similarity index 66% rename from app/src/main/java/com/example/it_da/ui/component/section/RecommendedProjectSection.kt rename to app/src/main/java/com/example/it_da/ui/commonComponent/section/RecommendedProjectSection.kt index fb389bc..cf3582e 100644 --- a/app/src/main/java/com/example/it_da/ui/component/section/RecommendedProjectSection.kt +++ b/app/src/main/java/com/example/it_da/ui/commonComponent/section/RecommendedProjectSection.kt @@ -1,4 +1,4 @@ -package com.example.it_da.ui.component.section +package com.example.it_da.ui.commonComponent.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.example.it_da.ui.component.ItdaSectionHeader -import com.example.it_da.ui.component.card.RecommendedProjectCard +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.commonComponent.ItdaSectionHeader +import com.example.it_da.ui.screen.home.component.card.RecommendedProjectCard import com.example.it_da.ui.screen.home.state.RecommendedProjectUiModel // Shows the recommended project section with independently clickable project cards. @@ -23,14 +25,14 @@ fun RecommendedProjectSection( ) { Column(modifier = modifier.fillMaxWidth()) { ItdaSectionHeader( - title = "추천 프로젝트", + title = stringResource(id = R.string.home_recommended_project_section_title), titleFontWeight = FontWeight.Bold ) - Spacer(modifier = Modifier.height(13.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.SectionHeaderContentSpacing)) Column( - verticalArrangement = Arrangement.spacedBy(15.dp), + verticalArrangement = Arrangement.spacedBy(ItdaLayoutDefaults.ProjectCardSpacing), modifier = Modifier.fillMaxWidth() ) { projects.forEach { project -> diff --git a/app/src/main/java/com/example/it_da/ui/component/card/HomeNotificationCard.kt b/app/src/main/java/com/example/it_da/ui/component/card/HomeNotificationCard.kt deleted file mode 100644 index de02b8b..0000000 --- a/app/src/main/java/com/example/it_da/ui/component/card/HomeNotificationCard.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.example.it_da.ui.component.card - -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -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.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.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.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.component.ItdaCardDefaults -import com.example.it_da.ui.screen.home.state.HomeNotificationUiModel -import com.example.it_da.ui.theme.DotSans -import com.example.it_da.ui.theme.ItdaSecondaryTextColor - -// Displays one notification summary row with state-provided image, message, and elapsed time. -@Composable -fun HomeNotificationCard( - notification: HomeNotificationUiModel, - onClick: (String) -> Unit, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 54.dp) - .clickable { - onClick(notification.id) - }, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - border = ItdaCardDefaults.outlinedBorder() - ) { - Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = painterResource(id = notification.imageResId), - contentDescription = notification.imageDescription, - modifier = Modifier.size(31.dp) - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Column { - Text( - text = notification.message, - color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = notification.elapsedTime, - color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 12.sp - ) - } - } - } -} diff --git a/app/src/main/java/com/example/it_da/ui/component/card/ParticipatingProjectCard.kt b/app/src/main/java/com/example/it_da/ui/component/card/ParticipatingProjectCard.kt deleted file mode 100644 index 420c7bd..0000000 --- a/app/src/main/java/com/example/it_da/ui/component/card/ParticipatingProjectCard.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.example.it_da.ui.component.card - -import androidx.compose.foundation.clickable -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.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.component.ItdaCardDefaults -import com.example.it_da.ui.component.ItdaOutlinedBadge -import com.example.it_da.ui.component.ItdaUnderlinedTextButton -import com.example.it_da.ui.screen.home.component.HomeProjectContentEndPadding -import com.example.it_da.ui.screen.home.component.HomeProjectContentStartPadding -import com.example.it_da.ui.screen.home.component.HomeProjectTitleStartPadding -import com.example.it_da.ui.screen.home.state.ParticipatingProjectUiModel -import com.example.it_da.ui.theme.DotSans -import com.example.it_da.ui.theme.ItdaPrimaryTextColor -import com.example.it_da.ui.theme.ItdaSecondaryTextColor - -// Displays one participating project with role and progress text supplied by state. -@Composable -fun ParticipatingProjectCard( - project: ParticipatingProjectUiModel, - onProjectClick: (String) -> Unit, - onDetailClick: (String) -> Unit, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 106.dp) - .clickable { - onProjectClick(project.id) - }, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - border = ItdaCardDefaults.outlinedBorder() - ) { - Column( - modifier = Modifier.padding( - top = 13.dp, - bottom = 12.dp - ) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding( - start = HomeProjectTitleStartPadding, - end = HomeProjectContentEndPadding - ) - ) { - Text( - text = project.title, - color = ItdaPrimaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - lineHeight = 18.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - ItdaOutlinedBadge(text = project.statusText) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = project.myRole, - color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding( - start = HomeProjectContentStartPadding, - end = HomeProjectContentEndPadding - ) - ) - - Spacer(modifier = Modifier.height(36.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding( - start = HomeProjectContentStartPadding, - end = HomeProjectContentEndPadding - ) - ) { - Text( - text = project.teamSummary, - color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 11.sp, - lineHeight = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - ItdaUnderlinedTextButton( - text = project.detailText, - onClick = { - onDetailClick(project.id) - } - ) - } - } - } -} diff --git a/app/src/main/java/com/example/it_da/ui/component/card/RecommendedProjectCard.kt b/app/src/main/java/com/example/it_da/ui/component/card/RecommendedProjectCard.kt deleted file mode 100644 index 0116f88..0000000 --- a/app/src/main/java/com/example/it_da/ui/component/card/RecommendedProjectCard.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.example.it_da.ui.component.card - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -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.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.rememberScrollState -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.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.component.ItdaCardDefaults -import com.example.it_da.ui.component.ItdaOutlinedBadge -import com.example.it_da.ui.component.ItdaUnderlinedTextButton -import com.example.it_da.ui.screen.home.component.HomeProjectContentEndPadding -import com.example.it_da.ui.screen.home.component.HomeProjectContentStartPadding -import com.example.it_da.ui.screen.home.component.HomeProjectTitleStartPadding -import com.example.it_da.ui.screen.home.state.RecommendedProjectUiModel -import com.example.it_da.ui.theme.DotSans -import com.example.it_da.ui.theme.ItdaPrimaryTextColor -import com.example.it_da.ui.theme.ItdaSecondaryTextColor - -// Displays one recommended project with state-provided title, status, stack, and participant text. -@Composable -fun RecommendedProjectCard( - project: RecommendedProjectUiModel, - onProjectClick: (String) -> Unit, - onDetailClick: (String) -> Unit, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 118.dp) - .clickable { - onProjectClick(project.id) - }, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - border = ItdaCardDefaults.outlinedBorder() - ) { - Column( - modifier = Modifier.padding( - top = 13.dp, - bottom = 12.dp - ) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding( - start = HomeProjectTitleStartPadding, - end = HomeProjectContentEndPadding - ) - ) { - Text( - text = project.title, - color = ItdaPrimaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - lineHeight = 18.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - ItdaOutlinedBadge(text = project.statusText) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = project.recruitingSummary, - color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding( - start = HomeProjectContentStartPadding, - end = HomeProjectContentEndPadding - ) - ) - - Spacer(modifier = Modifier.height(18.dp)) - - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier - .fillMaxWidth() - .padding( - start = HomeProjectContentStartPadding, - end = HomeProjectContentEndPadding - ) - .horizontalScroll(rememberScrollState()) - ) { - project.techStacks.forEach { techStack -> - ItdaOutlinedBadge(text = techStack) - } - } - - Spacer(modifier = Modifier.height(13.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding( - start = HomeProjectContentStartPadding, - end = HomeProjectContentEndPadding - ) - ) { - Box( - modifier = Modifier - .weight(1f) - .horizontalScroll(rememberScrollState()) - ) { - Text( - text = project.participantSummary, - color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 11.sp, - lineHeight = 11.sp, - maxLines = 1, - softWrap = false - ) - } - - ItdaUnderlinedTextButton( - text = project.detailText, - onClick = { - onDetailClick(project.id) - } - ) - } - } - } -} diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginButton.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginButton.kt index 76cdcfe..50efd0c 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginButton.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginButton.kt @@ -44,7 +44,7 @@ fun LoginButton( Box(contentAlignment = Alignment.Center) { Text( text = "로그인", - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.titleMedium ) } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginInputGroup.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginInputGroup.kt index cd6abb3..c359f57 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginInputGroup.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginInputGroup.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +private val LoginFieldSpacing = 20.dp + // Groups the id and password fields so their spacing stays consistent. @Composable fun LoginInputGroup( @@ -24,7 +26,7 @@ fun LoginInputGroup( placeholder = "아이디" ) - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(LoginFieldSpacing)) LoginUnderlineTextField( value = password, diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginIntroTextGroup.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginIntroTextGroup.kt index ae671cd..96f056d 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginIntroTextGroup.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginIntroTextGroup.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +private val LoginIntroTitleDescriptionSpacing = 28.dp + // Displays the main launch message as one grouped text element. @Composable fun LoginIntroTextGroup( @@ -33,7 +35,7 @@ fun LoginIntroTextGroup( textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(28.dp)) + Spacer(modifier = Modifier.height(LoginIntroTitleDescriptionSpacing)) Text( text = "로그인 한 번으로 당신의 포트폴리오 첫 줄이 바뀝니다.\n퍼즐 조각처럼 딱 맞는 파트너를 만나는 곳,", diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginUnderlineTextField.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginUnderlineTextField.kt index ec0259e..a6a51e0 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginUnderlineTextField.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/LoginUnderlineTextField.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.example.it_da.ui.theme.ItdaInputCursorColor import com.example.it_da.ui.theme.ItdaInputTextColor import com.example.it_da.ui.theme.ItdaInputUnderlineColor @@ -36,10 +35,8 @@ fun LoginUnderlineTextField( ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val textStyle = MaterialTheme.typography.bodyLarge.copy( - color = ItdaInputTextColor, - fontSize = 16.sp, - lineHeight = 20.sp + val textStyle = MaterialTheme.typography.labelLarge.copy( + color = ItdaInputTextColor ) Column( diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/component/SocialLoginButtonRow.kt b/app/src/main/java/com/example/it_da/ui/screen/login/component/SocialLoginButtonRow.kt index 3dee7e4..f992aef 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/component/SocialLoginButtonRow.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/component/SocialLoginButtonRow.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.example.it_da.R +private val SocialLoginButtonSpacing = 12.dp + // Places the social login image buttons in the order shown by the design. @Composable fun SocialLoginButtonRow( @@ -22,7 +24,7 @@ fun SocialLoginButtonRow( ) { Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(SocialLoginButtonSpacing) ) { SocialLoginButton( imageResId = R.drawable.ic_apple_login, diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAccountScreen.kt b/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAccountScreen.kt index 7bc4a3c..8dae0d3 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAccountScreen.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAccountScreen.kt @@ -13,20 +13,22 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.it_da.ui.screen.signup.component.SignUpOutlinedTextField -import com.example.it_da.ui.screen.signup.component.SignUpPrimaryButton -import com.example.it_da.ui.screen.signup.component.SignUpTopBar +import com.example.it_da.ui.commonComponent.ItdaOutlinedTextField +import com.example.it_da.ui.commonComponent.ItdaPrimaryButton +import com.example.it_da.ui.commonComponent.ItdaTopBar import com.example.it_da.ui.screen.signup.state.SignUpAccountUiState import com.example.it_da.ui.theme.ITDATheme import com.example.it_da.ui.theme.ItdaSecondaryTextColor -private val SignUpSectionTitleWeight = FontWeight.Medium -private val SignUpDescriptionWeight = FontWeight.Normal +private val SignUpAccountTopSpacing = 37.dp +private val SignUpAccountTitleDescriptionSpacing = 16.dp +private val SignUpAccountDescriptionFieldSpacing = 29.dp +private val SignUpAccountFieldSpacing = 21.dp +private val SignUpAccountBottomSpacing = 45.dp +private const val SignUpAccountFlexibleSpacingWeight = 1f // Assembles the first sign-up step from focused form components. @Composable @@ -45,7 +47,7 @@ fun SignUpAccountScreen( .statusBarsPadding() .navigationBarsPadding() ) { - SignUpTopBar(title = "회원 가입") + ItdaTopBar(title = "회원 가입") Column( modifier = Modifier @@ -53,42 +55,34 @@ fun SignUpAccountScreen( .weight(1f) .padding(horizontal = 32.dp) ) { - Spacer(modifier = Modifier.height(37.dp)) + Spacer(modifier = Modifier.height(SignUpAccountTopSpacing)) Text( text = "계정 만들기", color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = SignUpSectionTitleWeight, - fontSize = 21.sp, - lineHeight = 21.sp - ) + style = MaterialTheme.typography.titleLarge ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(SignUpAccountTitleDescriptionSpacing)) Text( text = "서비스를 이용하기 위해 기본 정보를 입력해 주세요", color = ItdaSecondaryTextColor, - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = SignUpDescriptionWeight, - fontSize = 13.sp, - lineHeight = 13.sp - ) + style = MaterialTheme.typography.bodyMedium ) - Spacer(modifier = Modifier.height(29.dp)) + Spacer(modifier = Modifier.height(SignUpAccountDescriptionFieldSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "아이디", value = uiState.id, onValueChange = onIdChange, placeholder = "6~15글자" ) - Spacer(modifier = Modifier.height(21.dp)) + Spacer(modifier = Modifier.height(SignUpAccountFieldSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "비밀번호", value = uiState.password, onValueChange = onPasswordChange, @@ -96,9 +90,9 @@ fun SignUpAccountScreen( visualTransformation = PasswordVisualTransformation() ) - Spacer(modifier = Modifier.height(21.dp)) + Spacer(modifier = Modifier.height(SignUpAccountFieldSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "비밀번호 확인", value = uiState.passwordConfirm, onValueChange = onPasswordConfirmChange, @@ -106,14 +100,14 @@ fun SignUpAccountScreen( visualTransformation = PasswordVisualTransformation() ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(SignUpAccountFlexibleSpacingWeight)) - SignUpPrimaryButton( + ItdaPrimaryButton( enabled = uiState.isNextEnabled, onClick = onNextClick ) - Spacer(modifier = Modifier.height(45.dp)) + Spacer(modifier = Modifier.height(SignUpAccountBottomSpacing)) } } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAdditionalInfoScreen.kt b/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAdditionalInfoScreen.kt index 73344d2..66bd4fb 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAdditionalInfoScreen.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/signup/screen/SignUpAdditionalInfoScreen.kt @@ -16,14 +16,18 @@ 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.example.it_da.ui.component.ItdaSectionHeader -import com.example.it_da.ui.screen.signup.component.SignUpDropdownTextField -import com.example.it_da.ui.screen.signup.component.SignUpOutlinedTextField -import com.example.it_da.ui.screen.signup.component.SignUpPrimaryButton -import com.example.it_da.ui.screen.signup.component.SignUpTopBar +import com.example.it_da.ui.commonComponent.ItdaSectionHeader +import com.example.it_da.ui.commonComponent.ItdaDropdownTextField +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.commonComponent.ItdaOutlinedTextField +import com.example.it_da.ui.commonComponent.ItdaPrimaryButton +import com.example.it_da.ui.commonComponent.ItdaTopBar import com.example.it_da.ui.screen.signup.state.SignUpAdditionalInfoUiState import com.example.it_da.ui.theme.ITDATheme +private val SignUpAdditionalTopSpacing = 31.dp +private val SignUpAdditionalHeaderFieldSpacing = 23.dp +private val SignUpAdditionalButtonSpacing = 120.dp // Assembles the second sign-up step for additional matching information. @Composable @@ -45,7 +49,7 @@ fun SignUpAdditionalInfoScreen( .statusBarsPadding() .navigationBarsPadding() ) { - SignUpTopBar(title = "추가 정보 입력") + ItdaTopBar(title = "추가 정보 입력") Column( modifier = Modifier @@ -53,25 +57,25 @@ fun SignUpAdditionalInfoScreen( .verticalScroll(rememberScrollState()) .padding(horizontal = 30.dp) ) { - Spacer(modifier = Modifier.height(31.dp)) + Spacer(modifier = Modifier.height(SignUpAdditionalTopSpacing)) ItdaSectionHeader( title = "추가 정보 입력", description = "매칭 품질을 높이기 위해 몇 가지 정보를 입력해 주세요" ) - Spacer(modifier = Modifier.height(23.dp)) + Spacer(modifier = Modifier.height(SignUpAdditionalHeaderFieldSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "이름", value = uiState.name, onValueChange = onNameChange, placeholder = "예: 홍길동, 가나다, 하지와레" ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.ShortVerticalSpacing)) - SignUpDropdownTextField( + ItdaDropdownTextField( label = "관심 분야", value = uiState.interestField, onValueChange = onInterestFieldChange, @@ -79,18 +83,18 @@ fun SignUpAdditionalInfoScreen( onArrowClick = onDropdownArrowClick ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.ShortVerticalSpacing)) - SignUpOutlinedTextField( + ItdaOutlinedTextField( label = "기술 스택", value = uiState.techStack, onValueChange = onTechStackChange, placeholder = "예: Python, Figma, Swift" ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.ShortVerticalSpacing)) - SignUpDropdownTextField( + ItdaDropdownTextField( label = "기수", value = uiState.cohort, onValueChange = onCohortChange, @@ -98,9 +102,9 @@ fun SignUpAdditionalInfoScreen( onArrowClick = onDropdownArrowClick ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(ItdaLayoutDefaults.ShortVerticalSpacing)) - SignUpDropdownTextField( + ItdaDropdownTextField( label = "학과", value = uiState.department, onValueChange = onDepartmentChange, @@ -108,9 +112,9 @@ fun SignUpAdditionalInfoScreen( onArrowClick = onDropdownArrowClick ) - Spacer(modifier = Modifier.height(120.dp)) + Spacer(modifier = Modifier.height(SignUpAdditionalButtonSpacing)) - SignUpPrimaryButton( + ItdaPrimaryButton( enabled = uiState.isNextEnabled, onClick = onNextClick ) diff --git a/app/src/main/java/com/example/it_da/ui/theme/Color.kt b/app/src/main/java/com/example/it_da/ui/theme/Color.kt index 9ee5ab0..cd06cfa 100644 --- a/app/src/main/java/com/example/it_da/ui/theme/Color.kt +++ b/app/src/main/java/com/example/it_da/ui/theme/Color.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color val ItdaWhite = Color(0xFFFFFFFF) val ItdaBlack = Color(0xFF000000) val ItdaTitleGray = Color(0xFF525252) +val ItdaSectionTextColor = Color(0xFF4F4F4F) val ItdaGuideGray = Color(0xFF8C8C8C) val ItdaInputBorderGray = Color(0xFF8C8C8C) val ItdaLineGray = Color(0x80545454) diff --git a/app/src/main/java/com/example/it_da/ui/theme/Font.kt b/app/src/main/java/com/example/it_da/ui/theme/Font.kt index e4e35cf..b8f7b05 100644 --- a/app/src/main/java/com/example/it_da/ui/theme/Font.kt +++ b/app/src/main/java/com/example/it_da/ui/theme/Font.kt @@ -20,15 +20,11 @@ val DotSans = FontFamily( ), Font( resId = R.font.dot_sans_medium, - weight = FontWeight(590) - ), - Font( - resId = R.font.dot_sans_medium, - weight = FontWeight(600) + weight = FontWeight.SemiBold ), Font( resId = R.font.dot_sans_bold, - weight = FontWeight(700) + weight = FontWeight.Bold ), Font( resId = R.font.dot_sans_extra_bold, diff --git a/app/src/main/java/com/example/it_da/ui/theme/Type.kt b/app/src/main/java/com/example/it_da/ui/theme/Type.kt index 8720b7f..d4d65f5 100644 --- a/app/src/main/java/com/example/it_da/ui/theme/Type.kt +++ b/app/src/main/java/com/example/it_da/ui/theme/Type.kt @@ -5,26 +5,76 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +private fun dotSansTextStyle( + fontWeight: FontWeight, + fontSize: Int +) = TextStyle( + fontFamily = DotSans, + fontWeight = fontWeight, + fontSize = fontSize.sp, + lineHeight = fontSize.sp, + letterSpacing = 0.sp +) + val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 16.sp, - letterSpacing = 0.sp + displayLarge = dotSansTextStyle( + fontWeight = FontWeight.Bold, + fontSize = 23 + ), + displayMedium = dotSansTextStyle( + fontWeight = FontWeight.Bold, + fontSize = 21 ), - titleLarge = TextStyle( - fontFamily = DotSans, + displaySmall = dotSansTextStyle( fontWeight = FontWeight.Bold, - fontSize = 21.sp, - lineHeight = 21.sp, - letterSpacing = 0.sp + fontSize = 20 + ), + headlineLarge = dotSansTextStyle( + fontWeight = FontWeight.Bold, + fontSize = 18 + ), + headlineMedium = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 19 + ), + headlineSmall = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 12 + ), + titleLarge = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 21 + ), + titleMedium = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16 + ), + titleSmall = dotSansTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 13 + ), + bodyLarge = dotSansTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 15 + ), + bodyMedium = dotSansTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 13 + ), + bodySmall = dotSansTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12 + ), + labelLarge = dotSansTextStyle( + fontWeight = FontWeight.Medium, + fontSize = 15 + ), + labelMedium = dotSansTextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11 ), - bodyMedium = TextStyle( - fontFamily = DotSans, + labelSmall = dotSansTextStyle( fontWeight = FontWeight.Normal, - fontSize = 13.sp, - lineHeight = 13.sp, - letterSpacing = 0.sp + fontSize = 10 ) ) diff --git a/app/src/main/res/drawable/backbutton.png b/app/src/main/res/drawable/backbutton.png new file mode 100644 index 0000000000000000000000000000000000000000..c89642b8eb423c4779667218cd9b4cf7d71024f2 GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0vp^E+EXo1|%(nCvO5$S3F%DLn`9l-rC5^WGLVgINgDB ziWBF#bLZBvST*o1V1Dt!Yr&Lc;UyQdI6QCsU(&m$bHB_kQ=mqM1O7dRdZ#z#6rYQ> z(^gNiNh+IZ`hH4c(wmQ7=PK8;h$}x6Ek3imzF(q=094YeRcce{5S(5wq^j8EDAKIK z7Ra+m!o{#7N#Ljgy1);yrQhpL>`vXGk-9&%ZiYtoJx%qvqqb??64z@${$j{@%lqoC Vr2dk%rXUY9c)I$ztaD0e0sw&}UF-k= literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/check.png b/app/src/main/res/drawable/check.png new file mode 100644 index 0000000000000000000000000000000000000000..e5b5ba4b415534261cc2d6b1b8b4793b735c3ee8 GIT binary patch literal 426 zcmV;b0agBqP)dqs6A5aRPN%d+Yyinggo3Isj!z<$4fLv<-| zs}2t{O|uTeaEE$0&0ep!83e&96+{p;;(@kp-%@=Fe$~S~&5W z$VOGwA@wkzJ#f#$Z-;h6F%a{iD9Q%$9CDrAY?35fqW%qhyEPc!Na(rf!TU4Uuy>cD zHWxHF5(DvG;kiH4j)iuUG)>#&_*|Mea8Fu}*v)Igf7bK7Q+n`M48zz(n+a*kZ=bEn zxGc+sYxM(!4oJ)CplKDy@gDUr1wyY$*k_hyA@#}7bQ~wfu3cbx2YH^isNv7}0?wq) UBTe$T{Qv*}07*qoM6N<$f($&xlmGw# literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/not_check.png b/app/src/main/res/drawable/not_check.png new file mode 100644 index 0000000000000000000000000000000000000000..add048432b1277aec475bda31faf1abce354c071 GIT binary patch literal 665 zcmV;K0%rY*P)p7{uy)s$1! zmD!%|r{9_GMA1K1oT#vMregI%@lIdq?hmQ)OKPP`I#-I@sfziWv1|Q3_j%$0|R3FKwJk0azOkwBMcIn#^ag0G}A((=#c) zFu=Z4&j7Q~@NKL-0$Y5g;;q-x%As`so%HylwDn1P{zZCmX@K9&8~z-H2E)ghDLgR* zF6=2?+LxB!NNaDU^%H3`$y^(i9vZ*_gmGqK263uFRBos^-;s<;-9C_3kNma0%579; zpj@9f4d!7sX8W^gQn{&kt}Wf%la|c2mk+O1ZY1t4xjIT;Ub8Ah_%4khKFXZ4iu$apV@qL;=0dBF{c=Ne1Ld&VgaV(pfrh5LM_24CXoUd zdwlnfUCc^P3Bb5im;QqK&NH8Y87Y%sr+{RP8{p%N7!zBKX6h;iU=~_wxkjNDFp4d_ z>yoW?osYA{eH&-SCRV`!2S7_6Y8wT(<(g%|9?m3CfL{zBXY*~-;jl_IO<16}5w#h` zz^%12)^cb3L54sNtz@BDaDWG+fGrfh8=4w{g*9E%)N~Fygy#wj3)NN0b4HeJk{Kny zJ>p!nwFg_600000NkvXXu0mjfz-2J! literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2e7033..c993426 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,67 @@ IT-DA - \ No newline at end of file + 다음으로 + 선택 목록 열기 + 자세히 보기 + Home + 프로젝트 탐색하기 + 추천 프로젝트 + 참여 중인 프로젝트 + 알림 요약ㆍ확인 + 모든 알림 보기 + 프로필 이미지 + 안녕하세요, %1$s님 👋 + 지원 중 + 참여 중 + 완료 + + 탐색 + 알림 + 프로필 + 프로젝트 추가 + Team create + 새 프로젝트 등록 + 기본 정보 + 설명 및 목표 + 모집 정보 + 프로젝트 이름 + 프로젝트 이름 + 카테고리 + 앱 개발 + 진행 기간 + 2개월 이내 + 진행 방식 + 온라인 + 프로젝트 소개 + 프로젝트 소개 + 목표 및 기대 결과 + 목표 및 기대 결과 + 모집 인원 + 1명 + 모집 역할 + 프론트엔드 + 필요 기술 스택 + 예: Kotlin, Figma + 지원 마감일 + 예: 2026-06-30 + 등록하기 + 뒤로가기 + 알림 + 알림 화면 뒤로가기 + 전체 읽음 처리 + 알림 유형 + 전체 + 안 읽음 + 읽음 + 알림 유형 선택 열기 + 새 프로젝트 추천 + 지원 결과 도착 + 팀 멤버 합류 + 나의 기술 스택과 일치하는 프로그램이 등록되었습니다. + 백엔드 개발자 1명이 팀에 합류했습니다. + 관심 분야 추천 프로젝트가 새로 업데이트 되었습니다. + 5분 전 + 1시간 전 + 읽음 + 안 읽음 + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255..54dbc25 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -6,8 +6,9 @@ See https://developer.android.com/about/versions/12/backup-restore --> + - \ No newline at end of file + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..0bdd38e 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,15 +5,13 @@ --> + - - \ No newline at end of file + From 9d9dbd6e5f69ee3b921b96b73671ba68b82172b8 Mon Sep 17 00:00:00 2001 From: cnsvkf Date: Sat, 30 May 2026 21:07:05 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor(home):=20=ED=99=88=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=EA=B3=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/FakeHomeRepository.kt | 212 ++++++++++++------ .../repository/FakeNotificationRepository.kt | 22 ++ .../repository/FakeProjectCreateRepository.kt | 26 +++ .../it_da/data/repository/HomeRepository.kt | 7 +- .../data/repository/NotificationRepository.kt | 14 ++ .../repository/ProjectCreateRepository.kt | 8 + .../data/store/DefaultNotificationStore.kt | 39 ++++ .../it_da/data/store/DefaultProjectStore.kt | 30 +++ .../it_da/data/store/DefaultUserStore.kt | 21 ++ .../it_da/data/store/NotificationStore.kt | 17 ++ .../example/it_da/data/store/ProjectStore.kt | 15 ++ .../com/example/it_da/data/store/UserStore.kt | 11 + .../it_da/domain/model/HomeDashboard.kt | 8 +- .../it_da/domain/model/HomeNotification.kt | 9 - .../domain/model/HomeNotificationType.kt | 7 - .../it_da/domain/model/Notification.kt | 11 + .../it_da/domain/model/NotificationType.kt | 7 + ...tingProject.kt => ParticipatingProject.kt} | 2 +- .../{HomeProjectCount.kt => ProjectCount.kt} | 4 +- .../domain/model/ProjectCreateProject.kt | 14 ++ .../it_da/domain/model/ProjectStoreState.kt | 12 + ...mendedProject.kt => RecommendedProject.kt} | 4 +- .../example/it_da/domain/model/UserSummary.kt | 7 + .../example/it_da/ui/screen/home/HomeRoute.kt | 13 +- .../it_da/ui/screen/home/HomeScreen.kt | 41 ++-- .../it_da/ui/screen/home/HomeUiStateMapper.kt | 30 +-- .../home/component/HomeBottomNavigationBar.kt | 170 -------------- .../component/card/HomeNotificationCard.kt | 75 +++++++ .../card/ParticipatingProjectCard.kt | 119 ++++++++++ .../component/card/RecommendedProjectCard.kt | 147 ++++++++++++ .../home/state/ParticipatingProjectUiModel.kt | 5 +- .../home/state/RecommendedProjectUiModel.kt | 5 +- .../ui/screen/home/viewmodel/HomeViewModel.kt | 32 ++- .../home/viewmodel/HomeViewModelFactory.kt | 17 -- .../store/DefaultNotificationStoreTest.kt | 50 +++++ .../data/store/DefaultProjectStoreTest.kt | 49 ++++ .../home/viewmodel/HomeViewModelTest.kt | 177 ++++++++++----- 37 files changed, 1036 insertions(+), 401 deletions(-) create mode 100644 app/src/main/java/com/example/it_da/data/repository/FakeNotificationRepository.kt create mode 100644 app/src/main/java/com/example/it_da/data/repository/FakeProjectCreateRepository.kt create mode 100644 app/src/main/java/com/example/it_da/data/repository/NotificationRepository.kt create mode 100644 app/src/main/java/com/example/it_da/data/repository/ProjectCreateRepository.kt create mode 100644 app/src/main/java/com/example/it_da/data/store/DefaultNotificationStore.kt create mode 100644 app/src/main/java/com/example/it_da/data/store/DefaultProjectStore.kt create mode 100644 app/src/main/java/com/example/it_da/data/store/DefaultUserStore.kt create mode 100644 app/src/main/java/com/example/it_da/data/store/NotificationStore.kt create mode 100644 app/src/main/java/com/example/it_da/data/store/ProjectStore.kt create mode 100644 app/src/main/java/com/example/it_da/data/store/UserStore.kt delete mode 100644 app/src/main/java/com/example/it_da/domain/model/HomeNotification.kt delete mode 100644 app/src/main/java/com/example/it_da/domain/model/HomeNotificationType.kt create mode 100644 app/src/main/java/com/example/it_da/domain/model/Notification.kt create mode 100644 app/src/main/java/com/example/it_da/domain/model/NotificationType.kt rename app/src/main/java/com/example/it_da/domain/model/{HomeParticipatingProject.kt => ParticipatingProject.kt} (86%) rename app/src/main/java/com/example/it_da/domain/model/{HomeProjectCount.kt => ProjectCount.kt} (55%) create mode 100644 app/src/main/java/com/example/it_da/domain/model/ProjectCreateProject.kt create mode 100644 app/src/main/java/com/example/it_da/domain/model/ProjectStoreState.kt rename app/src/main/java/com/example/it_da/domain/model/{HomeRecommendedProject.kt => RecommendedProject.kt} (66%) create mode 100644 app/src/main/java/com/example/it_da/domain/model/UserSummary.kt delete mode 100644 app/src/main/java/com/example/it_da/ui/screen/home/component/HomeBottomNavigationBar.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/home/component/card/HomeNotificationCard.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/home/component/card/ParticipatingProjectCard.kt create mode 100644 app/src/main/java/com/example/it_da/ui/screen/home/component/card/RecommendedProjectCard.kt delete mode 100644 app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelFactory.kt create mode 100644 app/src/test/java/com/example/it_da/data/store/DefaultNotificationStoreTest.kt create mode 100644 app/src/test/java/com/example/it_da/data/store/DefaultProjectStoreTest.kt diff --git a/app/src/main/java/com/example/it_da/data/repository/FakeHomeRepository.kt b/app/src/main/java/com/example/it_da/data/repository/FakeHomeRepository.kt index c2b4488..1c4c6c7 100644 --- a/app/src/main/java/com/example/it_da/data/repository/FakeHomeRepository.kt +++ b/app/src/main/java/com/example/it_da/data/repository/FakeHomeRepository.kt @@ -1,74 +1,158 @@ package com.example.it_da.data.repository +import com.example.it_da.data.store.NotificationStore +import com.example.it_da.data.store.ProjectStore +import com.example.it_da.data.store.UserStore import com.example.it_da.domain.model.HomeDashboard -import com.example.it_da.domain.model.HomeNotification -import com.example.it_da.domain.model.HomeNotificationType -import com.example.it_da.domain.model.HomeParticipatingProject -import com.example.it_da.domain.model.HomeProjectCount -import com.example.it_da.domain.model.HomeRecommendedProject +import com.example.it_da.domain.model.Notification +import com.example.it_da.domain.model.NotificationType +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectCount +import com.example.it_da.domain.model.ProjectStoreState +import com.example.it_da.domain.model.RecommendedProject +import com.example.it_da.domain.model.UserSummary +import javax.inject.Inject +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock -class FakeHomeRepository : HomeRepository { - // Returns sample home data through the same repository boundary that a server implementation will use. - override suspend fun getHomeDashboard(): Result { - return Result.success( - HomeDashboard( - userName = "000", - greetingDescription = "상상은 여기서 현실이 됩니다.\n당신의 프로젝트와 팀을 찾아보세요", - projectCount = HomeProjectCount( - applyingCount = 3, - participatingCount = 1, - completedCount = 1 - ), - recommendedProjects = listOf( - HomeRecommendedProject( - id = "recommended-ai-planner", - title = "AI 기반 학습 플래너 [0부0부]", - recruitingSummary = "백엔드 개발자 1명 모집", - statusText = "모집 중", - techStacks = listOf("Back-end"), - participantSummary = "IoT과ㆍ2명, SW과 1명 참여" - ), - HomeRecommendedProject( - id = "recommended-hachiware", - title = "하지와레 키우기 [하키]", - recruitingSummary = "프론트엔드 개발자 2명 모집", - statusText = "모집 중", - techStacks = listOf("Back-end"), - participantSummary = "IoT과ㆍ2명, SW과 1명 참여" - ), - HomeRecommendedProject( - id = "recommended-pokemon", - title = "닮은 포켓몬 검사 [포켓몬백]", - recruitingSummary = "iOS 개발자ㆍ1명ㆍ디자이너 1명 모집", - statusText = "마감 임박", - techStacks = listOf("iOS", "Design"), - participantSummary = "IoT과ㆍ2명, SW과 1명 참여" - ) - ), - participatingProjects = listOf( - HomeParticipatingProject( - id = "participating-dalbal", - title = "사랑을 이어주는 앱 [달발]", - myRole = "내 역할 : iOS 개발", - statusText = "진행 중", - teamSummary = "팀원 4명ㆍ마감 2026-05-31" +private const val HomeNotificationSummaryLimit = 2 + +class FakeHomeRepository @Inject constructor( + private val userStore: UserStore, + private val projectStore: ProjectStore, + private val notificationStore: NotificationStore +) : HomeRepository { + private val initializationMutex = Mutex() + private var isInitialized = false + + override val homeDashboard = combine( + userStore.userSummary, + projectStore.state, + notificationStore.notifications + ) { userSummary, projectState, notifications -> + HomeDashboard( + userName = userSummary.userName, + greetingDescription = userSummary.greetingDescription, + projectCount = projectState.projectCount, + recommendedProjects = projectState.recommendedProjects, + participatingProjects = projectState.participatingProjects, + notifications = notifications.take(HomeNotificationSummaryLimit) + ) + } + + // Loads sample server data once so later Store updates survive home screen re-entry. + override suspend fun refreshDashboard(): Result { + return runCatching { + initializationMutex.withLock { + if (isInitialized) { + return@withLock + } + + val dashboard = createInitialHomeDashboard() + userStore.replaceUserSummary( + UserSummary( + userName = dashboard.userName, + greetingDescription = dashboard.greetingDescription ) - ), - notifications = listOf( - HomeNotification( - id = "notification-message", - type = HomeNotificationType.MESSAGE, - message = "지원한 프로젝트에서 새 메시지가 있습니다", - elapsedTime = "2분전" - ), - HomeNotification( - id = "notification-join", - type = HomeNotificationType.PROJECT_JOIN, - message = "백엔드 개발자 1명이 프로젝트에 합류 하였습니다", - elapsedTime = "2분전" + ) + projectStore.replaceState( + ProjectStoreState( + projectCount = dashboard.projectCount, + recommendedProjects = dashboard.recommendedProjects, + participatingProjects = dashboard.participatingProjects ) ) + notificationStore.replaceNotifications(dashboard.notifications) + isInitialized = true + } + } + } +} + +// Supplies fake server values until dashboard endpoints are connected. +private fun createInitialHomeDashboard(): HomeDashboard { + return HomeDashboard( + userName = "000", + greetingDescription = "상상은 여기서 현실이 됩니다.\n당신의 프로젝트와 팀을 찾아보세요", + projectCount = ProjectCount( + applyingCount = 3, + participatingCount = 1, + completedCount = 1 + ), + recommendedProjects = listOf( + RecommendedProject( + id = "recommended-ai-planner", + title = "AI 기반 학습 플래너 [0부0부]", + recruitingSummary = "백엔드 개발자 1명 모집", + statusText = "모집 중", + techStacks = listOf("Back-end"), + participantSummary = "IoT과ㆍ2명, SW과 1명 참여" + ), + RecommendedProject( + id = "recommended-hachiware", + title = "하지와레 키우기 [하키]", + recruitingSummary = "프론트엔드 개발자 2명 모집", + statusText = "모집 중", + techStacks = listOf("Back-end"), + participantSummary = "IoT과ㆍ2명, SW과 1명 참여" + ), + RecommendedProject( + id = "recommended-pokemon", + title = "닮은 포켓몬 검사 [포켓몬백]", + recruitingSummary = "iOS 개발자ㆍ1명ㆍ디자이너 1명 모집", + statusText = "마감 임박", + techStacks = listOf("iOS", "Design"), + participantSummary = "IoT과ㆍ2명, SW과 1명 참여" + ) + ), + participatingProjects = listOf( + ParticipatingProject( + id = "participating-dalbal", + title = "사랑을 이어주는 앱 [달발]", + myRole = "내 역할 : iOS 개발", + statusText = "진행 중", + teamSummary = "팀원 4명ㆍ마감 2026-05-31" ) + ), + notifications = createInitialNotifications() + ) +} + +// Supplies one shared fake notification list for both the home summary and notification screen. +private fun createInitialNotifications(): List { + return listOf( + Notification( + id = "new-project-recommendation", + type = NotificationType.MESSAGE, + title = "새 프로젝트 추천", + message = "나의 기술 스택과 일치하는 프로그램이 등록되었습니다.", + elapsedTime = "5분 전", + isRead = false + ), + Notification( + id = "application-result", + type = NotificationType.MESSAGE, + title = "지원 결과 도착", + message = "백엔드 개발자 1명이 팀에 합류했습니다.", + elapsedTime = "1시간 전", + isRead = false + ), + Notification( + id = "team-member-joined", + type = NotificationType.PROJECT_JOIN, + title = "팀 멤버 합류", + message = "백엔드 개발자 1명이 팀에 합류했습니다.", + elapsedTime = "1시간 전", + isRead = true + ), + Notification( + id = "updated-project-recommendation", + type = NotificationType.MESSAGE, + title = "새 프로젝트 추천", + message = "관심 분야 추천 프로젝트가 새로 업데이트 되었습니다.", + elapsedTime = "1시간 전", + isRead = true ) - } + ) } diff --git a/app/src/main/java/com/example/it_da/data/repository/FakeNotificationRepository.kt b/app/src/main/java/com/example/it_da/data/repository/FakeNotificationRepository.kt new file mode 100644 index 0000000..caa6f5a --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/FakeNotificationRepository.kt @@ -0,0 +1,22 @@ +package com.example.it_da.data.repository + +import com.example.it_da.data.store.NotificationStore +import javax.inject.Inject + +class FakeNotificationRepository @Inject constructor( + private val notificationStore: NotificationStore +) : NotificationRepository { + override val notifications = notificationStore.notifications + + // Updates shared fake notification state until a server endpoint is connected. + override suspend fun markAsRead(notificationId: String): Result { + notificationStore.markAsRead(notificationId) + return Result.success(Unit) + } + + // Updates every shared fake notification until a server endpoint is connected. + override suspend fun markAllAsRead(): Result { + notificationStore.markAllAsRead() + return Result.success(Unit) + } +} diff --git a/app/src/main/java/com/example/it_da/data/repository/FakeProjectCreateRepository.kt b/app/src/main/java/com/example/it_da/data/repository/FakeProjectCreateRepository.kt new file mode 100644 index 0000000..39e2154 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/FakeProjectCreateRepository.kt @@ -0,0 +1,26 @@ +package com.example.it_da.data.repository + +import com.example.it_da.domain.model.ProjectCreateProject +import com.example.it_da.data.store.ProjectStore +import com.example.it_da.domain.model.ParticipatingProject +import javax.inject.Inject + +class FakeProjectCreateRepository @Inject constructor( + private val projectStore: ProjectStore +) : ProjectCreateRepository { + private var nextCreatedProjectId = 1 + + // Adds a fake server-created project to shared Store state until the real endpoint is connected. + override suspend fun createProject(projectCreateProject: ProjectCreateProject): Result { + projectStore.addCreatedProject( + ParticipatingProject( + id = "created-project-${nextCreatedProjectId++}", + title = projectCreateProject.projectName, + myRole = "내 역할 : 프로젝트 생성자", + statusText = "모집 중", + teamSummary = "팀원 1명ㆍ마감 ${projectCreateProject.deadline}" + ) + ) + return Result.success(Unit) + } +} diff --git a/app/src/main/java/com/example/it_da/data/repository/HomeRepository.kt b/app/src/main/java/com/example/it_da/data/repository/HomeRepository.kt index 3633aef..e9a77c8 100644 --- a/app/src/main/java/com/example/it_da/data/repository/HomeRepository.kt +++ b/app/src/main/java/com/example/it_da/data/repository/HomeRepository.kt @@ -1,8 +1,11 @@ package com.example.it_da.data.repository import com.example.it_da.domain.model.HomeDashboard +import kotlinx.coroutines.flow.Flow interface HomeRepository { - // Loads the home dashboard data that will later come from the server. - suspend fun getHomeDashboard(): Result + val homeDashboard: Flow + + // Refreshes shared Store values from the dashboard source without exposing data sources to UI. + suspend fun refreshDashboard(): Result } diff --git a/app/src/main/java/com/example/it_da/data/repository/NotificationRepository.kt b/app/src/main/java/com/example/it_da/data/repository/NotificationRepository.kt new file mode 100644 index 0000000..ff5fe2b --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/NotificationRepository.kt @@ -0,0 +1,14 @@ +package com.example.it_da.data.repository + +import com.example.it_da.domain.model.Notification +import kotlinx.coroutines.flow.Flow + +interface NotificationRepository { + val notifications: Flow> + + // Marks one notification as read through the data layer boundary. + suspend fun markAsRead(notificationId: String): Result + + // Marks all notifications as read through the data layer boundary. + suspend fun markAllAsRead(): Result +} diff --git a/app/src/main/java/com/example/it_da/data/repository/ProjectCreateRepository.kt b/app/src/main/java/com/example/it_da/data/repository/ProjectCreateRepository.kt new file mode 100644 index 0000000..9a3efc8 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/ProjectCreateRepository.kt @@ -0,0 +1,8 @@ +package com.example.it_da.data.repository + +import com.example.it_da.domain.model.ProjectCreateProject + +interface ProjectCreateRepository { + // Submits project creation values through the data layer boundary. + suspend fun createProject(projectCreateProject: ProjectCreateProject): Result +} diff --git a/app/src/main/java/com/example/it_da/data/store/DefaultNotificationStore.kt b/app/src/main/java/com/example/it_da/data/store/DefaultNotificationStore.kt new file mode 100644 index 0000000..9d44ccd --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/DefaultNotificationStore.kt @@ -0,0 +1,39 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.Notification +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class DefaultNotificationStore @Inject constructor() : NotificationStore { + private val _notifications = MutableStateFlow>(emptyList()) + override val notifications = _notifications.asStateFlow() + + // Publishes the latest notification list as the shared in-memory value. + override fun replaceNotifications(notifications: List) { + _notifications.value = notifications + } + + // Updates only the selected notification while preserving list order. + override fun markAsRead(notificationId: String) { + _notifications.update { notifications -> + notifications.map { notification -> + if (notification.id == notificationId) { + notification.copy(isRead = true) + } else { + notification + } + } + } + } + + // Updates all notifications with a single shared state publication. + override fun markAllAsRead() { + _notifications.update { notifications -> + notifications.map { notification -> + notification.copy(isRead = true) + } + } + } +} diff --git a/app/src/main/java/com/example/it_da/data/store/DefaultProjectStore.kt b/app/src/main/java/com/example/it_da/data/store/DefaultProjectStore.kt new file mode 100644 index 0000000..36422f3 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/DefaultProjectStore.kt @@ -0,0 +1,30 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectStoreState +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class DefaultProjectStore @Inject constructor() : ProjectStore { + private val _state = MutableStateFlow(ProjectStoreState()) + override val state = _state.asStateFlow() + + // Publishes a complete project snapshot so all project consumers stay consistent. + override fun replaceState(state: ProjectStoreState) { + _state.value = state + } + + // Prepends a created project and increments the participating count in one state update. + override fun addCreatedProject(project: ParticipatingProject) { + _state.update { currentState -> + currentState.copy( + projectCount = currentState.projectCount.copy( + participatingCount = currentState.projectCount.participatingCount + 1 + ), + participatingProjects = listOf(project) + currentState.participatingProjects + ) + } + } +} diff --git a/app/src/main/java/com/example/it_da/data/store/DefaultUserStore.kt b/app/src/main/java/com/example/it_da/data/store/DefaultUserStore.kt new file mode 100644 index 0000000..c1518df --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/DefaultUserStore.kt @@ -0,0 +1,21 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.UserSummary +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class DefaultUserStore @Inject constructor() : UserStore { + private val _userSummary = MutableStateFlow( + UserSummary( + userName = "", + greetingDescription = "" + ) + ) + override val userSummary = _userSummary.asStateFlow() + + // Publishes the latest user summary as the shared in-memory value. + override fun replaceUserSummary(userSummary: UserSummary) { + _userSummary.value = userSummary + } +} diff --git a/app/src/main/java/com/example/it_da/data/store/NotificationStore.kt b/app/src/main/java/com/example/it_da/data/store/NotificationStore.kt new file mode 100644 index 0000000..a35c8f2 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/NotificationStore.kt @@ -0,0 +1,17 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.Notification +import kotlinx.coroutines.flow.StateFlow + +interface NotificationStore { + val notifications: StateFlow> + + // Replaces shared notifications after a repository refresh succeeds. + fun replaceNotifications(notifications: List) + + // Marks one shared notification as read for every observing screen. + fun markAsRead(notificationId: String) + + // Marks every shared notification as read for every observing screen. + fun markAllAsRead() +} diff --git a/app/src/main/java/com/example/it_da/data/store/ProjectStore.kt b/app/src/main/java/com/example/it_da/data/store/ProjectStore.kt new file mode 100644 index 0000000..61c4d2d --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/ProjectStore.kt @@ -0,0 +1,15 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectStoreState +import kotlinx.coroutines.flow.StateFlow + +interface ProjectStore { + val state: StateFlow + + // Replaces all shared project values after a repository refresh succeeds. + fun replaceState(state: ProjectStoreState) + + // Adds a newly created project to the current user's participating projects. + fun addCreatedProject(project: ParticipatingProject) +} diff --git a/app/src/main/java/com/example/it_da/data/store/UserStore.kt b/app/src/main/java/com/example/it_da/data/store/UserStore.kt new file mode 100644 index 0000000..21a6943 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/store/UserStore.kt @@ -0,0 +1,11 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.UserSummary +import kotlinx.coroutines.flow.StateFlow + +interface UserStore { + val userSummary: StateFlow + + // Replaces shared user summary values after a repository loads authoritative data. + fun replaceUserSummary(userSummary: UserSummary) +} diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeDashboard.kt b/app/src/main/java/com/example/it_da/domain/model/HomeDashboard.kt index 5524e9c..9ae9d86 100644 --- a/app/src/main/java/com/example/it_da/domain/model/HomeDashboard.kt +++ b/app/src/main/java/com/example/it_da/domain/model/HomeDashboard.kt @@ -4,8 +4,8 @@ package com.example.it_da.domain.model data class HomeDashboard( val userName: String, val greetingDescription: String, - val projectCount: HomeProjectCount, - val recommendedProjects: List, - val participatingProjects: List, - val notifications: List + val projectCount: ProjectCount, + val recommendedProjects: List, + val participatingProjects: List, + val notifications: List ) diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeNotification.kt b/app/src/main/java/com/example/it_da/domain/model/HomeNotification.kt deleted file mode 100644 index da22179..0000000 --- a/app/src/main/java/com/example/it_da/domain/model/HomeNotification.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.it_da.domain.model - -// Represents one notification summary received from the home data source. -data class HomeNotification( - val id: String, - val type: HomeNotificationType, - val message: String, - val elapsedTime: String -) diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeNotificationType.kt b/app/src/main/java/com/example/it_da/domain/model/HomeNotificationType.kt deleted file mode 100644 index 1b17c0a..0000000 --- a/app/src/main/java/com/example/it_da/domain/model/HomeNotificationType.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.it_da.domain.model - -// Identifies which app image should represent a home notification. -enum class HomeNotificationType { - MESSAGE, - PROJECT_JOIN -} diff --git a/app/src/main/java/com/example/it_da/domain/model/Notification.kt b/app/src/main/java/com/example/it_da/domain/model/Notification.kt new file mode 100644 index 0000000..c1cc10a --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/Notification.kt @@ -0,0 +1,11 @@ +package com.example.it_da.domain.model + +// Represents one notification shared by the home summary and notification screen. +data class Notification( + val id: String, + val type: NotificationType, + val title: String, + val message: String, + val elapsedTime: String, + val isRead: Boolean +) diff --git a/app/src/main/java/com/example/it_da/domain/model/NotificationType.kt b/app/src/main/java/com/example/it_da/domain/model/NotificationType.kt new file mode 100644 index 0000000..c16a5cd --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/NotificationType.kt @@ -0,0 +1,7 @@ +package com.example.it_da.domain.model + +// Identifies the shared category of an app notification. +enum class NotificationType { + MESSAGE, + PROJECT_JOIN +} diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeParticipatingProject.kt b/app/src/main/java/com/example/it_da/domain/model/ParticipatingProject.kt similarity index 86% rename from app/src/main/java/com/example/it_da/domain/model/HomeParticipatingProject.kt rename to app/src/main/java/com/example/it_da/domain/model/ParticipatingProject.kt index c25bf31..b202b49 100644 --- a/app/src/main/java/com/example/it_da/domain/model/HomeParticipatingProject.kt +++ b/app/src/main/java/com/example/it_da/domain/model/ParticipatingProject.kt @@ -1,7 +1,7 @@ package com.example.it_da.domain.model // Represents a project that the current user is already participating in. -data class HomeParticipatingProject( +data class ParticipatingProject( val id: String, val title: String, val myRole: String, diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeProjectCount.kt b/app/src/main/java/com/example/it_da/domain/model/ProjectCount.kt similarity index 55% rename from app/src/main/java/com/example/it_da/domain/model/HomeProjectCount.kt rename to app/src/main/java/com/example/it_da/domain/model/ProjectCount.kt index 53c8f32..b287292 100644 --- a/app/src/main/java/com/example/it_da/domain/model/HomeProjectCount.kt +++ b/app/src/main/java/com/example/it_da/domain/model/ProjectCount.kt @@ -1,7 +1,7 @@ package com.example.it_da.domain.model -// Represents the user's project activity counts from the home data source. -data class HomeProjectCount( +// Represents the user's shared project activity counts. +data class ProjectCount( val applyingCount: Int, val participatingCount: Int, val completedCount: Int diff --git a/app/src/main/java/com/example/it_da/domain/model/ProjectCreateProject.kt b/app/src/main/java/com/example/it_da/domain/model/ProjectCreateProject.kt new file mode 100644 index 0000000..9048f0f --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/ProjectCreateProject.kt @@ -0,0 +1,14 @@ +package com.example.it_da.domain.model + +data class ProjectCreateProject( + val projectName: String, + val category: String, + val period: String, + val method: String, + val introduction: String, + val goal: String, + val memberCount: String, + val role: String, + val techStack: String, + val deadline: String +) diff --git a/app/src/main/java/com/example/it_da/domain/model/ProjectStoreState.kt b/app/src/main/java/com/example/it_da/domain/model/ProjectStoreState.kt new file mode 100644 index 0000000..f158776 --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/ProjectStoreState.kt @@ -0,0 +1,12 @@ +package com.example.it_da.domain.model + +// Groups project values so Store updates publish one consistent project snapshot. +data class ProjectStoreState( + val projectCount: ProjectCount = ProjectCount( + applyingCount = 0, + participatingCount = 0, + completedCount = 0 + ), + val recommendedProjects: List = emptyList(), + val participatingProjects: List = emptyList() +) diff --git a/app/src/main/java/com/example/it_da/domain/model/HomeRecommendedProject.kt b/app/src/main/java/com/example/it_da/domain/model/RecommendedProject.kt similarity index 66% rename from app/src/main/java/com/example/it_da/domain/model/HomeRecommendedProject.kt rename to app/src/main/java/com/example/it_da/domain/model/RecommendedProject.kt index 95ac873..2a3bfd8 100644 --- a/app/src/main/java/com/example/it_da/domain/model/HomeRecommendedProject.kt +++ b/app/src/main/java/com/example/it_da/domain/model/RecommendedProject.kt @@ -1,7 +1,7 @@ package com.example.it_da.domain.model -// Represents a recommended project received from the home data source. -data class HomeRecommendedProject( +// Represents a recommended project shared by project-related screens. +data class RecommendedProject( val id: String, val title: String, val recruitingSummary: String, diff --git a/app/src/main/java/com/example/it_da/domain/model/UserSummary.kt b/app/src/main/java/com/example/it_da/domain/model/UserSummary.kt new file mode 100644 index 0000000..1be45a9 --- /dev/null +++ b/app/src/main/java/com/example/it_da/domain/model/UserSummary.kt @@ -0,0 +1,7 @@ +package com.example.it_da.domain.model + +// Represents user information shared by screens that display profile summaries. +data class UserSummary( + val userName: String, + val greetingDescription: String +) diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt b/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt index e7e704f..d46e8f0 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt @@ -3,14 +3,15 @@ package com.example.it_da.ui.screen.home import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.example.it_da.ui.screen.home.viewmodel.HomeViewModel -import com.example.it_da.ui.screen.home.viewmodel.HomeViewModelFactory // Connects HomeViewModel state to the home screen and leaves future navigation targets as callbacks. @Composable fun HomeRoute( - viewModel: HomeViewModel = viewModel(factory = HomeViewModelFactory()) + onCreateProjectClick: () -> Unit, + onNotificationClick: () -> Unit, + viewModel: HomeViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -21,12 +22,12 @@ fun HomeRoute( onParticipatingProjectClick = {}, onParticipatingProjectDetailClick = {}, onNotificationClick = {}, - onViewAllNotificationsClick = {}, + onViewAllNotificationsClick = onNotificationClick, onExploreProjectsClick = {}, onHomeTabClick = {}, onExploreTabClick = {}, - onCreateProjectClick = {}, - onNotificationTabClick = {}, + onCreateProjectClick = onCreateProjectClick, + onNotificationTabClick = onNotificationClick, onProfileTabClick = {} ) } diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/HomeScreen.kt b/app/src/main/java/com/example/it_da/ui/screen/home/HomeScreen.kt index ff9a4e6..a188ae3 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/HomeScreen.kt @@ -1,12 +1,11 @@ package com.example.it_da.ui.screen.home 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding @@ -16,16 +15,18 @@ import androidx.compose.material3.MaterialTheme 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.example.it_da.R -import com.example.it_da.ui.screen.home.component.HomeBottomNavigationBar -import com.example.it_da.ui.component.section.HomeNotificationSection -import com.example.it_da.ui.component.section.HomeProfileSummarySection -import com.example.it_da.ui.component.section.ParticipatingProjectSection -import com.example.it_da.ui.component.section.RecommendedProjectSection -import com.example.it_da.ui.screen.signup.component.SignUpPrimaryButton -import com.example.it_da.ui.screen.signup.component.SignUpTopBar +import com.example.it_da.ui.commonComponent.ItdaBottomNavigationBar +import com.example.it_da.ui.commonComponent.ItdaLayoutDefaults +import com.example.it_da.ui.commonComponent.ItdaPrimaryButton +import com.example.it_da.ui.commonComponent.ItdaTopBar +import com.example.it_da.ui.commonComponent.section.HomeNotificationSection +import com.example.it_da.ui.commonComponent.section.HomeProfileSummarySection +import com.example.it_da.ui.commonComponent.section.ParticipatingProjectSection +import com.example.it_da.ui.commonComponent.section.RecommendedProjectSection import com.example.it_da.ui.screen.home.state.HomeNotificationUiModel import com.example.it_da.ui.screen.home.state.HomeProjectCountUiModel import com.example.it_da.ui.screen.home.state.HomeUiState @@ -59,7 +60,7 @@ fun HomeScreen( .statusBarsPadding() ) { Column(modifier = Modifier.fillMaxSize()) { - SignUpTopBar(title = "Home") + ItdaTopBar(title = stringResource(id = R.string.home_top_bar_title)) HomeContent( uiState = uiState, @@ -74,7 +75,7 @@ fun HomeScreen( ) } - HomeBottomNavigationBar( + ItdaBottomNavigationBar( onHomeClick = onHomeTabClick, onExploreClick = onExploreTabClick, onCreateProjectClick = onCreateProjectClick, @@ -105,10 +106,10 @@ private fun HomeContent( .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(horizontal = 38.dp) - .padding(bottom = 80.dp) + .padding(top = ItdaLayoutDefaults.LongVerticalSpacing) + .padding(bottom = ItdaLayoutDefaults.BottomNavigationContentPadding), + verticalArrangement = Arrangement.spacedBy(ItdaLayoutDefaults.LongVerticalSpacing) ) { - Spacer(modifier = Modifier.height(24.dp)) - HomeProfileSummarySection( profileImageResId = uiState.profileImageResId, userName = uiState.userName, @@ -116,36 +117,28 @@ private fun HomeContent( projectCount = uiState.projectCount ) - Spacer(modifier = Modifier.height(22.dp)) - RecommendedProjectSection( projects = uiState.recommendedProjects, onProjectClick = onRecommendedProjectClick, onDetailClick = onRecommendedProjectDetailClick ) - Spacer(modifier = Modifier.height(28.dp)) - ParticipatingProjectSection( projects = uiState.participatingProjects, onProjectClick = onParticipatingProjectClick, onDetailClick = onParticipatingProjectDetailClick ) - Spacer(modifier = Modifier.height(28.dp)) - HomeNotificationSection( notifications = uiState.notifications, onNotificationClick = onNotificationClick, onViewAllClick = onViewAllNotificationsClick ) - Spacer(modifier = Modifier.height(28.dp)) - - SignUpPrimaryButton( + ItdaPrimaryButton( enabled = true, onClick = onExploreProjectsClick, - text = "프로젝트 탐색하기", + text = stringResource(id = R.string.home_explore_projects_button), containerColor = ItdaHomeExploreButtonGray ) } diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/HomeUiStateMapper.kt b/app/src/main/java/com/example/it_da/ui/screen/home/HomeUiStateMapper.kt index f448e46..5193789 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/HomeUiStateMapper.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/HomeUiStateMapper.kt @@ -2,11 +2,11 @@ package com.example.it_da.ui.screen.home import com.example.it_da.R import com.example.it_da.domain.model.HomeDashboard -import com.example.it_da.domain.model.HomeNotification -import com.example.it_da.domain.model.HomeNotificationType -import com.example.it_da.domain.model.HomeParticipatingProject -import com.example.it_da.domain.model.HomeProjectCount -import com.example.it_da.domain.model.HomeRecommendedProject +import com.example.it_da.domain.model.Notification +import com.example.it_da.domain.model.NotificationType +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectCount +import com.example.it_da.domain.model.RecommendedProject import com.example.it_da.ui.screen.home.state.HomeNotificationUiModel import com.example.it_da.ui.screen.home.state.HomeProjectCountUiModel import com.example.it_da.ui.screen.home.state.HomeUiState @@ -33,7 +33,7 @@ fun HomeDashboard.toHomeUiState(): HomeUiState { } // Converts domain project count data into the count model used by the Home header. -private fun HomeProjectCount.toHomeProjectCountUiModel(): HomeProjectCountUiModel { +private fun ProjectCount.toHomeProjectCountUiModel(): HomeProjectCountUiModel { return HomeProjectCountUiModel( applyingCount = applyingCount, participatingCount = participatingCount, @@ -42,7 +42,7 @@ private fun HomeProjectCount.toHomeProjectCountUiModel(): HomeProjectCountUiMode } // Converts a domain recommended project into the card model used by the Home UI. -private fun HomeRecommendedProject.toRecommendedProjectUiModel(): RecommendedProjectUiModel { +private fun RecommendedProject.toRecommendedProjectUiModel(): RecommendedProjectUiModel { return RecommendedProjectUiModel( id = id, title = title, @@ -54,7 +54,7 @@ private fun HomeRecommendedProject.toRecommendedProjectUiModel(): RecommendedPro } // Converts a domain participating project into the card model used by the Home UI. -private fun HomeParticipatingProject.toParticipatingProjectUiModel(): ParticipatingProjectUiModel { +private fun ParticipatingProject.toParticipatingProjectUiModel(): ParticipatingProjectUiModel { return ParticipatingProjectUiModel( id = id, title = title, @@ -65,7 +65,7 @@ private fun HomeParticipatingProject.toParticipatingProjectUiModel(): Participat } // Converts a domain notification into the image-backed model used by the Home UI. -private fun HomeNotification.toHomeNotificationUiModel(): HomeNotificationUiModel { +private fun Notification.toHomeNotificationUiModel(): HomeNotificationUiModel { return HomeNotificationUiModel( id = id, imageResId = type.toNotificationImageResId(), @@ -76,17 +76,17 @@ private fun HomeNotification.toHomeNotificationUiModel(): HomeNotificationUiMode } // Maps notification type values to drawable resources owned by the UI layer. -private fun HomeNotificationType.toNotificationImageResId(): Int { +private fun NotificationType.toNotificationImageResId(): Int { return when (this) { - HomeNotificationType.MESSAGE -> R.drawable.home_notification_mailbox - HomeNotificationType.PROJECT_JOIN -> R.drawable.home_notification_laptop + NotificationType.MESSAGE -> R.drawable.home_notification_mailbox + NotificationType.PROJECT_JOIN -> R.drawable.home_notification_laptop } } // Maps notification type values to accessibility descriptions for notification images. -private fun HomeNotificationType.toNotificationImageDescription(): String { +private fun NotificationType.toNotificationImageDescription(): String { return when (this) { - HomeNotificationType.MESSAGE -> "새 메시지 알림" - HomeNotificationType.PROJECT_JOIN -> "프로젝트 참여 알림" + NotificationType.MESSAGE -> "새 메시지 알림" + NotificationType.PROJECT_JOIN -> "프로젝트 참여 알림" } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/component/HomeBottomNavigationBar.kt b/app/src/main/java/com/example/it_da/ui/screen/home/component/HomeBottomNavigationBar.kt deleted file mode 100644 index 08538cf..0000000 --- a/app/src/main/java/com/example/it_da/ui/screen/home/component/HomeBottomNavigationBar.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.example.it_da.ui.screen.home.component - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -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.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource -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.example.it_da.R -import com.example.it_da.ui.theme.DotSans -import com.example.it_da.ui.theme.ItdaHomeDividerGray -import com.example.it_da.ui.theme.ItdaSecondaryTextColor -import com.example.it_da.ui.theme.ItdaWhite - -private val HomeBottomNavigationBarHeight = 52.5.dp -private val HomeBottomNavigationContainerHeight = 62.dp -private val HomeBottomNavigationItemHeight = 48.dp - -// Shows the fixed bottom navigation bar and exposes each tab as a callback. -@Composable -fun HomeBottomNavigationBar( - onHomeClick: () -> Unit, - onExploreClick: () -> Unit, - onCreateProjectClick: () -> Unit, - onNotificationClick: () -> Unit, - onProfileClick: () -> Unit, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .fillMaxWidth() - .height(HomeBottomNavigationContainerHeight) - ) { - Surface( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(HomeBottomNavigationBarHeight), - color = ItdaWhite, - border = BorderStroke(1.dp, ItdaHomeDividerGray) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - HomeBottomNavigationItem( - iconResId = R.drawable.bottom_bar_home, - label = "홈", - contentDescription = "홈", - iconSize = 25.dp, - onClick = onHomeClick - ) - - HomeBottomNavigationItem( - iconResId = R.drawable.bottom_bar_research, - label = "탐색", - contentDescription = "탐색", - iconSize = 25.dp, - onClick = onExploreClick - ) - - Spacer( - modifier = Modifier - .width(52.dp) - .height(HomeBottomNavigationItemHeight) - ) - - HomeBottomNavigationItem( - iconResId = R.drawable.bottom_bar_bell, - label = "알림", - contentDescription = "알림", - iconSize = 25.dp, - onClick = onNotificationClick - ) - - HomeBottomNavigationItem( - iconResId = R.drawable.bottom_bar_profile, - label = "프로필", - contentDescription = "프로필", - iconSize = 25.dp, - onClick = onProfileClick - ) - } - } - - HomeCenterNavigationButton( - onClick = onCreateProjectClick, - modifier = Modifier.align(Alignment.TopCenter) - ) - } -} - -// Shows a normal labeled bottom navigation item. -@Composable -private fun HomeBottomNavigationItem( - @DrawableRes iconResId: Int, - label: String, - contentDescription: String, - iconSize: Dp, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .width(52.dp) - .height(HomeBottomNavigationItemHeight) - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Image( - painter = painterResource(id = iconResId), - contentDescription = contentDescription, - modifier = Modifier.size(iconSize) - ) - - Spacer(modifier = Modifier.height(2.dp)) - - Text( - text = label, - color = ItdaSecondaryTextColor, - fontFamily = DotSans, - fontWeight = FontWeight.Normal, - fontSize = 9.sp, - lineHeight = 9.sp - ) - } -} - -// Shows the center add-project action as the prominent middle bottom button. -@Composable -private fun HomeCenterNavigationButton( - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .size(45.dp) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource(id = R.drawable.bottom_bar_center), - contentDescription = "프로젝트 추가", - modifier = Modifier.size(45.dp) - ) - } -} diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/component/card/HomeNotificationCard.kt b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/HomeNotificationCard.kt new file mode 100644 index 0000000..233ce3a --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/HomeNotificationCard.kt @@ -0,0 +1,75 @@ +package com.example.it_da.ui.screen.home.component.card + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.it_da.ui.commonComponent.ItdaCard +import com.example.it_da.ui.screen.home.state.HomeNotificationUiModel +import com.example.it_da.ui.theme.ItdaSecondaryTextColor + +private val HomeNotificationImageContentSpacing = 16.dp +private val HomeNotificationMessageTimeSpacing = 8.dp + +// Displays one notification summary row with state-provided image, message, and elapsed time. +@Composable +fun HomeNotificationCard( + notification: HomeNotificationUiModel, + onClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + ItdaCard( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 54.dp) + .clickable { + onClick(notification.id) + } + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = notification.imageResId), + contentDescription = notification.imageDescription, + modifier = Modifier.size(31.dp) + ) + + Spacer(modifier = Modifier.width(HomeNotificationImageContentSpacing)) + + Column { + Text( + text = notification.message, + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(HomeNotificationMessageTimeSpacing)) + + Text( + text = notification.elapsedTime, + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/component/card/ParticipatingProjectCard.kt b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/ParticipatingProjectCard.kt new file mode 100644 index 0000000..039857a --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/ParticipatingProjectCard.kt @@ -0,0 +1,119 @@ +package com.example.it_da.ui.screen.home.component.card + +import androidx.compose.foundation.clickable +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaCard +import com.example.it_da.ui.commonComponent.ItdaOutlinedBadge +import com.example.it_da.ui.commonComponent.ItdaUnderlinedTextButton +import com.example.it_da.ui.screen.home.component.HomeProjectContentEndPadding +import com.example.it_da.ui.screen.home.component.HomeProjectContentStartPadding +import com.example.it_da.ui.screen.home.component.HomeProjectTitleStartPadding +import com.example.it_da.ui.screen.home.state.ParticipatingProjectUiModel +import com.example.it_da.ui.theme.ItdaPrimaryTextColor +import com.example.it_da.ui.theme.ItdaSecondaryTextColor + +private val ParticipatingProjectTitleRoleSpacing = 8.dp +private val ParticipatingProjectRoleTeamSpacing = 36.dp + +// Displays one participating project with role and progress text supplied by state. +@Composable +fun ParticipatingProjectCard( + project: ParticipatingProjectUiModel, + onProjectClick: (String) -> Unit, + onDetailClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + ItdaCard( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 106.dp) + .clickable { + onProjectClick(project.id) + } + ) { + Column( + modifier = Modifier.padding( + top = 13.dp, + bottom = 12.dp + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + start = HomeProjectTitleStartPadding, + end = HomeProjectContentEndPadding + ) + ) { + Text( + text = project.title, + color = ItdaPrimaryTextColor, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + ItdaOutlinedBadge(text = project.statusText) + } + + Spacer(modifier = Modifier.height(ParticipatingProjectTitleRoleSpacing)) + + Text( + text = project.myRole, + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding( + start = HomeProjectContentStartPadding, + end = HomeProjectContentEndPadding + ) + ) + + Spacer(modifier = Modifier.height(ParticipatingProjectRoleTeamSpacing)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + start = HomeProjectContentStartPadding, + end = HomeProjectContentEndPadding + ) + ) { + Text( + text = project.teamSummary, + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + ItdaUnderlinedTextButton( + text = stringResource(id = R.string.common_view_details), + onClick = { + onDetailClick(project.id) + } + ) + } + } + } +} diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/component/card/RecommendedProjectCard.kt b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/RecommendedProjectCard.kt new file mode 100644 index 0000000..7ed6830 --- /dev/null +++ b/app/src/main/java/com/example/it_da/ui/screen/home/component/card/RecommendedProjectCard.kt @@ -0,0 +1,147 @@ +package com.example.it_da.ui.screen.home.component.card + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.it_da.R +import com.example.it_da.ui.commonComponent.ItdaCard +import com.example.it_da.ui.commonComponent.ItdaOutlinedBadge +import com.example.it_da.ui.commonComponent.ItdaUnderlinedTextButton +import com.example.it_da.ui.screen.home.component.HomeProjectContentEndPadding +import com.example.it_da.ui.screen.home.component.HomeProjectContentStartPadding +import com.example.it_da.ui.screen.home.component.HomeProjectTitleStartPadding +import com.example.it_da.ui.screen.home.state.RecommendedProjectUiModel +import com.example.it_da.ui.theme.ItdaPrimaryTextColor +import com.example.it_da.ui.theme.ItdaSecondaryTextColor + +private val RecommendedProjectTitleRecruitSpacing = 8.dp +private val RecommendedProjectRecruitStackSpacing = 18.dp +private val RecommendedProjectTechStackSpacing = 6.dp +private val RecommendedProjectStackParticipantsSpacing = 13.dp + +// Displays one recommended project with state-provided title, status, stack, and participant text. +@Composable +fun RecommendedProjectCard( + project: RecommendedProjectUiModel, + onProjectClick: (String) -> Unit, + onDetailClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + ItdaCard( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 118.dp) + .clickable { + onProjectClick(project.id) + } + ) { + Column( + modifier = Modifier.padding( + top = 13.dp, + bottom = 12.dp + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + start = HomeProjectTitleStartPadding, + end = HomeProjectContentEndPadding + ) + ) { + Text( + text = project.title, + color = ItdaPrimaryTextColor, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + ItdaOutlinedBadge(text = project.statusText) + } + + Spacer(modifier = Modifier.height(RecommendedProjectTitleRecruitSpacing)) + + Text( + text = project.recruitingSummary, + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding( + start = HomeProjectContentStartPadding, + end = HomeProjectContentEndPadding + ) + ) + + Spacer(modifier = Modifier.height(RecommendedProjectRecruitStackSpacing)) + + Row( + horizontalArrangement = Arrangement.spacedBy(RecommendedProjectTechStackSpacing), + modifier = Modifier + .fillMaxWidth() + .padding( + start = HomeProjectContentStartPadding, + end = HomeProjectContentEndPadding + ) + .horizontalScroll(rememberScrollState()) + ) { + project.techStacks.forEach { techStack -> + ItdaOutlinedBadge(text = techStack) + } + } + + Spacer(modifier = Modifier.height(RecommendedProjectStackParticipantsSpacing)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + start = HomeProjectContentStartPadding, + end = HomeProjectContentEndPadding + ) + ) { + Box( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()) + ) { + Text( + text = project.participantSummary, + color = ItdaSecondaryTextColor, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + softWrap = false + ) + } + + ItdaUnderlinedTextButton( + text = stringResource(id = R.string.common_view_details), + onClick = { + onDetailClick(project.id) + } + ) + } + } + } +} diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/state/ParticipatingProjectUiModel.kt b/app/src/main/java/com/example/it_da/ui/screen/home/state/ParticipatingProjectUiModel.kt index 4828fdc..bb0d1f1 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/state/ParticipatingProjectUiModel.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/state/ParticipatingProjectUiModel.kt @@ -1,11 +1,10 @@ package com.example.it_da.ui.screen.home.state -// Represents a project the user is already participating in. +// Represents server-provided participating project values displayed by one project card. data class ParticipatingProjectUiModel( val id: String, val title: String, val myRole: String, val statusText: String, - val teamSummary: String, - val detailText: String = "자세히 보기" + val teamSummary: String ) diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/state/RecommendedProjectUiModel.kt b/app/src/main/java/com/example/it_da/ui/screen/home/state/RecommendedProjectUiModel.kt index 68ca42e..a236d1a 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/state/RecommendedProjectUiModel.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/state/RecommendedProjectUiModel.kt @@ -1,12 +1,11 @@ package com.example.it_da.ui.screen.home.state -// Represents a recommended project card whose visible text can come from remote data later. +// Represents server-provided recommended project values displayed by one project card. data class RecommendedProjectUiModel( val id: String, val title: String, val recruitingSummary: String, val statusText: String, val techStacks: List, - val participantSummary: String, - val detailText: String = "자세히 보기" + val participantSummary: String ) diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModel.kt b/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModel.kt index 5221627..af5fa48 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModel.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModel.kt @@ -5,27 +5,35 @@ import androidx.lifecycle.viewModelScope import com.example.it_da.data.repository.HomeRepository import com.example.it_da.ui.screen.home.toHomeUiState import com.example.it_da.ui.screen.home.state.HomeUiState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -class HomeViewModel( +@HiltViewModel +class HomeViewModel @Inject constructor( private val homeRepository: HomeRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(HomeUiState()) - val uiState = _uiState.asStateFlow() + val uiState = homeRepository.homeDashboard + .map { homeDashboard -> + homeDashboard.toHomeUiState() + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = HomeUiState() + ) init { - loadHomeDashboard() + refreshHomeDashboard() } - // Loads home dashboard values from the repository and exposes them as UI state. - private fun loadHomeDashboard() { + // Requests dashboard refresh while ongoing Store changes continue to update UI state. + private fun refreshHomeDashboard() { viewModelScope.launch { - homeRepository.getHomeDashboard() - .onSuccess { homeDashboard -> - _uiState.value = homeDashboard.toHomeUiState() - } + homeRepository.refreshDashboard() } } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelFactory.kt b/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelFactory.kt deleted file mode 100644 index eaae17a..0000000 --- a/app/src/main/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.it_da.ui.screen.home.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.it_da.data.repository.FakeHomeRepository - -class HomeViewModelFactory : ViewModelProvider.Factory { - // Creates HomeViewModel with a fake repository until the server-backed repository is ready. - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(HomeViewModel::class.java)) { - return HomeViewModel(FakeHomeRepository()) as T - } - - throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") - } -} diff --git a/app/src/test/java/com/example/it_da/data/store/DefaultNotificationStoreTest.kt b/app/src/test/java/com/example/it_da/data/store/DefaultNotificationStoreTest.kt new file mode 100644 index 0000000..166411a --- /dev/null +++ b/app/src/test/java/com/example/it_da/data/store/DefaultNotificationStoreTest.kt @@ -0,0 +1,50 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.Notification +import com.example.it_da.domain.model.NotificationType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DefaultNotificationStoreTest { + @Test + fun markAsReadUpdatesOnlySelectedNotification() { + val store = createNotificationStore() + + store.markAsRead("first") + + assertTrue(store.notifications.value.first { it.id == "first" }.isRead) + assertFalse(store.notifications.value.first { it.id == "second" }.isRead) + } + + @Test + fun markAllAsReadUpdatesEveryNotification() { + val store = createNotificationStore() + + store.markAllAsRead() + + assertTrue(store.notifications.value.all(Notification::isRead)) + } + + private fun createNotificationStore(): DefaultNotificationStore { + return DefaultNotificationStore().apply { + replaceNotifications( + listOf( + createNotification("first"), + createNotification("second") + ) + ) + } + } + + private fun createNotification(id: String): Notification { + return Notification( + id = id, + type = NotificationType.MESSAGE, + title = "알림 제목", + message = "알림 내용", + elapsedTime = "방금 전", + isRead = false + ) + } +} diff --git a/app/src/test/java/com/example/it_da/data/store/DefaultProjectStoreTest.kt b/app/src/test/java/com/example/it_da/data/store/DefaultProjectStoreTest.kt new file mode 100644 index 0000000..5db1e7f --- /dev/null +++ b/app/src/test/java/com/example/it_da/data/store/DefaultProjectStoreTest.kt @@ -0,0 +1,49 @@ +package com.example.it_da.data.store + +import com.example.it_da.domain.model.ParticipatingProject +import com.example.it_da.domain.model.ProjectCount +import com.example.it_da.domain.model.ProjectStoreState +import org.junit.Assert.assertEquals +import org.junit.Test + +class DefaultProjectStoreTest { + @Test + fun addCreatedProjectPrependsProjectAndIncrementsParticipatingCount() { + val store = DefaultProjectStore() + store.replaceState( + ProjectStoreState( + projectCount = ProjectCount( + applyingCount = 3, + participatingCount = 1, + completedCount = 1 + ), + participatingProjects = listOf( + createParticipatingProject(id = "existing", title = "기존 프로젝트") + ) + ) + ) + + store.addCreatedProject( + createParticipatingProject(id = "created", title = "새 프로젝트") + ) + + assertEquals(2, store.state.value.projectCount.participatingCount) + assertEquals( + listOf("created", "existing"), + store.state.value.participatingProjects.map(ParticipatingProject::id) + ) + } + + private fun createParticipatingProject( + id: String, + title: String + ): ParticipatingProject { + return ParticipatingProject( + id = id, + title = title, + myRole = "내 역할", + statusText = "진행 중", + teamSummary = "팀 정보" + ) + } +} diff --git a/app/src/test/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelTest.kt b/app/src/test/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelTest.kt index e28b488..6e1a5ea 100644 --- a/app/src/test/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelTest.kt +++ b/app/src/test/java/com/example/it_da/ui/screen/home/viewmodel/HomeViewModelTest.kt @@ -1,17 +1,18 @@ package com.example.it_da.ui.screen.home.viewmodel -import com.example.it_da.data.repository.HomeRepository -import com.example.it_da.domain.model.HomeDashboard -import com.example.it_da.domain.model.HomeNotification -import com.example.it_da.domain.model.HomeNotificationType -import com.example.it_da.domain.model.HomeParticipatingProject -import com.example.it_da.domain.model.HomeProjectCount -import com.example.it_da.domain.model.HomeRecommendedProject +import com.example.it_da.data.repository.FakeHomeRepository +import com.example.it_da.data.store.DefaultNotificationStore +import com.example.it_da.data.store.DefaultProjectStore +import com.example.it_da.data.store.DefaultUserStore +import com.example.it_da.domain.model.Notification +import com.example.it_da.domain.model.NotificationType +import com.example.it_da.domain.model.ParticipatingProject import com.example.it_da.testing.MainDispatcherRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -21,67 +22,123 @@ class HomeViewModelTest { val mainDispatcherRule = MainDispatcherRule() @Test - fun loadsHomeDashboardFromRepositoryIntoUiState() = runTest( + fun refreshesDashboardAndMapsSharedStoreValuesIntoUiState() = runTest( mainDispatcherRule.testDispatcher ) { - val homeDashboard = HomeDashboard( - userName = "서버사용자", - greetingDescription = "서버에서 받은 소개 문구", - projectCount = HomeProjectCount( - applyingCount = 7, - participatingCount = 2, - completedCount = 4 - ), - recommendedProjects = listOf( - HomeRecommendedProject( - id = "server-recommended", - title = "서버 추천 프로젝트", - recruitingSummary = "서버 모집 요약", - statusText = "서버 상태", - techStacks = listOf("iOS", "Design", "Back-end"), - participantSummary = "서버 참여 요약" - ) - ), - participatingProjects = listOf( - HomeParticipatingProject( - id = "server-participating", - title = "서버 참여 프로젝트", - myRole = "서버 역할", - statusText = "서버 진행 상태", - teamSummary = "서버 팀 요약" - ) - ), - notifications = listOf( - HomeNotification( - id = "server-notification", - type = HomeNotificationType.MESSAGE, - message = "서버 알림", - elapsedTime = "방금 전" - ) + val viewModel = createHomeViewModel() + + advanceUntilIdle() + + val uiState = viewModel.uiState.value + assertEquals("000", uiState.userName) + assertEquals(3, uiState.projectCount.applyingCount) + assertEquals("AI 기반 학습 플래너 [0부0부]", uiState.recommendedProjects.first().title) + assertEquals("사랑을 이어주는 앱 [달발]", uiState.participatingProjects.first().title) + assertEquals( + listOf("new-project-recommendation", "application-result"), + uiState.notifications.map { notification -> notification.id } + ) + } + + @Test + fun updatesUiStateWhenProjectStorePublishesCreatedProject() = runTest( + mainDispatcherRule.testDispatcher + ) { + val projectStore = DefaultProjectStore() + val viewModel = createHomeViewModel(projectStore = projectStore) + advanceUntilIdle() + + projectStore.addCreatedProject( + ParticipatingProject( + id = "created-project", + title = "새 프로젝트", + myRole = "내 역할 : 프로젝트 생성자", + statusText = "모집 중", + teamSummary = "팀원 1명ㆍ마감 2026-06-30" ) ) - val viewModel = HomeViewModel(StaticHomeRepository(homeDashboard)) + advanceUntilIdle() + assertEquals(2, viewModel.uiState.value.projectCount.participatingCount) + assertEquals("새 프로젝트", viewModel.uiState.value.participatingProjects.first().title) + } + + @Test + fun refreshDoesNotOverwriteStoreChangesAfterInitialLoad() = runTest( + mainDispatcherRule.testDispatcher + ) { + val projectStore = DefaultProjectStore() + val notificationStore = DefaultNotificationStore() + val repository = FakeHomeRepository( + userStore = DefaultUserStore(), + projectStore = projectStore, + notificationStore = notificationStore + ) + val viewModel = HomeViewModel(repository) advanceUntilIdle() - val uiState = viewModel.uiState.value - assertEquals("서버사용자", uiState.userName) - assertEquals("서버에서 받은 소개 문구", uiState.greetingDescription) - assertEquals(7, uiState.projectCount.applyingCount) - assertEquals("서버 추천 프로젝트", uiState.recommendedProjects.first().title) - assertEquals(listOf("iOS", "Design", "Back-end"), uiState.recommendedProjects.first().techStacks) - assertEquals("서버 참여 프로젝트", uiState.participatingProjects.first().title) - assertEquals("서버 역할", uiState.participatingProjects.first().myRole) - assertEquals("서버 팀 요약", uiState.participatingProjects.first().teamSummary) - assertEquals("서버 알림", uiState.notifications.first().message) + projectStore.addCreatedProject( + ParticipatingProject( + id = "created-project", + title = "새 프로젝트", + myRole = "내 역할 : 프로젝트 생성자", + statusText = "모집 중", + teamSummary = "팀원 1명ㆍ마감 2026-06-30" + ) + ) + notificationStore.markAllAsRead() + + repository.refreshDashboard() + advanceUntilIdle() + + assertEquals("새 프로젝트", viewModel.uiState.value.participatingProjects.first().title) + assertTrue(notificationStore.notifications.value.all(Notification::isRead)) } - private class StaticHomeRepository( - private val homeDashboard: HomeDashboard - ) : HomeRepository { - // Returns a fixed dashboard so the ViewModel mapping can be verified deterministically. - override suspend fun getHomeDashboard(): Result { - return Result.success(homeDashboard) - } + @Test + fun limitsHomeNotificationSummaryToLatestTwoSharedNotifications() = runTest( + mainDispatcherRule.testDispatcher + ) { + val notificationStore = DefaultNotificationStore() + val viewModel = createHomeViewModel(notificationStore = notificationStore) + advanceUntilIdle() + + notificationStore.replaceNotifications( + listOf( + createNotification("latest"), + createNotification("second"), + createNotification("third") + ) + ) + advanceUntilIdle() + + assertEquals( + listOf("latest", "second"), + viewModel.uiState.value.notifications.map { notification -> notification.id } + ) + } + + private fun createHomeViewModel( + projectStore: DefaultProjectStore = DefaultProjectStore(), + notificationStore: DefaultNotificationStore = DefaultNotificationStore() + ): HomeViewModel { + return HomeViewModel( + FakeHomeRepository( + userStore = DefaultUserStore(), + projectStore = projectStore, + notificationStore = notificationStore + ) + ) + } + + private fun createNotification(id: String): Notification { + return Notification( + id = id, + type = NotificationType.MESSAGE, + title = "알림 제목", + message = "알림 내용", + elapsedTime = "방금 전", + isRead = false + ) } } From 5383e5a958603129ba52a0c2747969797e4d1c69 Mon Sep 17 00:00:00 2001 From: cnsvkf Date: Sat, 30 May 2026 21:33:05 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor(di):=20Hilt=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultAuthTokenLocalDataSourceTest.kt | 39 ++++++++++ .../it_da/data/auth/SocialAuthSessionStore.kt | 9 +-- .../data/local/AuthTokenLocalDataSource.kt | 12 +++ .../local/DefaultAuthTokenLocalDataSource.kt | 44 +++++++++++ .../data/repository/AuthSessionRepository.kt | 12 +++ .../DefaultAuthSessionRepository.kt | 33 ++++++++ .../repository/DefaultSocialAuthRepository.kt | 6 +- .../com/example/it_da/di/RepositoryModule.kt | 75 ++++++++++++++++++ .../com/example/it_da/di/SocialAuthModule.kt | 25 ++++++ .../DefaultAuthSessionRepositoryTest.kt | 77 +++++++++++++++++++ 10 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 app/src/androidTest/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSourceTest.kt create mode 100644 app/src/main/java/com/example/it_da/data/local/AuthTokenLocalDataSource.kt create mode 100644 app/src/main/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSource.kt create mode 100644 app/src/main/java/com/example/it_da/data/repository/AuthSessionRepository.kt create mode 100644 app/src/main/java/com/example/it_da/data/repository/DefaultAuthSessionRepository.kt create mode 100644 app/src/main/java/com/example/it_da/di/RepositoryModule.kt create mode 100644 app/src/main/java/com/example/it_da/di/SocialAuthModule.kt create mode 100644 app/src/test/java/com/example/it_da/data/repository/DefaultAuthSessionRepositoryTest.kt diff --git a/app/src/androidTest/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSourceTest.kt b/app/src/androidTest/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSourceTest.kt new file mode 100644 index 0000000..9b6ad6d --- /dev/null +++ b/app/src/androidTest/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSourceTest.kt @@ -0,0 +1,39 @@ +package com.example.it_da.data.local + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultAuthTokenLocalDataSourceTest { + private val localDataSource = DefaultAuthTokenLocalDataSource( + InstrumentationRegistry.getInstrumentation().targetContext + ) + + @Before + fun clearTokenBeforeTest() = runBlocking { + localDataSource.clearToken() + } + + @After + fun clearTokenAfterTest() = runBlocking { + localDataSource.clearToken() + } + + @Test + fun savesLoadsAndClearsTokenInPreferencesDataStore() = runBlocking { + localDataSource.saveToken("stored-token") + + assertEquals("stored-token", localDataSource.getToken()) + + localDataSource.clearToken() + + assertNull(localDataSource.getToken()) + } +} diff --git a/app/src/main/java/com/example/it_da/data/auth/SocialAuthSessionStore.kt b/app/src/main/java/com/example/it_da/data/auth/SocialAuthSessionStore.kt index 73ac990..095395f 100644 --- a/app/src/main/java/com/example/it_da/data/auth/SocialAuthSessionStore.kt +++ b/app/src/main/java/com/example/it_da/data/auth/SocialAuthSessionStore.kt @@ -1,10 +1,13 @@ package com.example.it_da.data.auth import com.example.it_da.domain.model.SocialAuthAccount +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -class SocialAuthSessionStore { +@Singleton +class SocialAuthSessionStore @Inject constructor() { private val _currentAccount = MutableStateFlow(null) val currentAccount = _currentAccount.asStateFlow() @@ -17,8 +20,4 @@ class SocialAuthSessionStore { fun clear() { _currentAccount.value = null } - - companion object { - val default = SocialAuthSessionStore() - } } diff --git a/app/src/main/java/com/example/it_da/data/local/AuthTokenLocalDataSource.kt b/app/src/main/java/com/example/it_da/data/local/AuthTokenLocalDataSource.kt new file mode 100644 index 0000000..757cc9a --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/local/AuthTokenLocalDataSource.kt @@ -0,0 +1,12 @@ +package com.example.it_da.data.local + +interface AuthTokenLocalDataSource { + // Loads the locally stored authentication token used by the launch session check. + suspend fun getToken(): String? + + // Persists the authentication token so the next app launch can restore the session. + suspend fun saveToken(token: String) + + // Removes the locally stored authentication token when the session should end. + suspend fun clearToken() +} diff --git a/app/src/main/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSource.kt b/app/src/main/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSource.kt new file mode 100644 index 0000000..29b1179 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/local/DefaultAuthTokenLocalDataSource.kt @@ -0,0 +1,44 @@ +package com.example.it_da.data.local + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first + +private const val AuthSessionDataStoreName = "auth_session" + +private val Context.authSessionDataStore by preferencesDataStore( + name = AuthSessionDataStoreName +) + +@Singleton +class DefaultAuthTokenLocalDataSource @Inject constructor( + @param:ApplicationContext private val context: Context +) : AuthTokenLocalDataSource { + // Loads the token from Preferences DataStore for the app launch session decision. + override suspend fun getToken(): String? { + return context.authSessionDataStore.data.first()[AuthTokenKey] + } + + // Stores the token in Preferences DataStore after a temporary authentication success. + override suspend fun saveToken(token: String) { + context.authSessionDataStore.edit { preferences -> + preferences[AuthTokenKey] = token + } + } + + // Deletes the token from Preferences DataStore for a future logout flow. + override suspend fun clearToken() { + context.authSessionDataStore.edit { preferences -> + preferences.remove(AuthTokenKey) + } + } + + private companion object { + val AuthTokenKey = stringPreferencesKey("auth_token") + } +} diff --git a/app/src/main/java/com/example/it_da/data/repository/AuthSessionRepository.kt b/app/src/main/java/com/example/it_da/data/repository/AuthSessionRepository.kt new file mode 100644 index 0000000..d76e355 --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/AuthSessionRepository.kt @@ -0,0 +1,12 @@ +package com.example.it_da.data.repository + +interface AuthSessionRepository { + // Checks whether a non-empty local token is available for automatic login. + suspend fun hasStoredToken(): Result + + // Saves a temporary token until the server authentication contract is connected. + suspend fun saveTemporaryToken(): Result + + // Clears the stored token for the future logout flow. + suspend fun clearToken(): Result +} diff --git a/app/src/main/java/com/example/it_da/data/repository/DefaultAuthSessionRepository.kt b/app/src/main/java/com/example/it_da/data/repository/DefaultAuthSessionRepository.kt new file mode 100644 index 0000000..f13ae3c --- /dev/null +++ b/app/src/main/java/com/example/it_da/data/repository/DefaultAuthSessionRepository.kt @@ -0,0 +1,33 @@ +package com.example.it_da.data.repository + +import com.example.it_da.data.local.AuthTokenLocalDataSource +import javax.inject.Inject +import javax.inject.Singleton + +private const val TemporaryAuthToken = "temporary-auth-token" + +@Singleton +class DefaultAuthSessionRepository @Inject constructor( + private val authTokenLocalDataSource: AuthTokenLocalDataSource +) : AuthSessionRepository { + // Treats any non-empty local token as an authenticated session until server validation is connected. + override suspend fun hasStoredToken(): Result { + return runCatching { + !authTokenLocalDataSource.getToken().isNullOrBlank() + } + } + + // Persists a placeholder token so the pre-server authentication flow can be verified end to end. + override suspend fun saveTemporaryToken(): Result { + return runCatching { + authTokenLocalDataSource.saveToken(TemporaryAuthToken) + } + } + + // Removes the current token behind the repository boundary for the future logout UI. + override suspend fun clearToken(): Result { + return runCatching { + authTokenLocalDataSource.clearToken() + } + } +} diff --git a/app/src/main/java/com/example/it_da/data/repository/DefaultSocialAuthRepository.kt b/app/src/main/java/com/example/it_da/data/repository/DefaultSocialAuthRepository.kt index 781a96b..c3de6d2 100644 --- a/app/src/main/java/com/example/it_da/data/repository/DefaultSocialAuthRepository.kt +++ b/app/src/main/java/com/example/it_da/data/repository/DefaultSocialAuthRepository.kt @@ -4,9 +4,11 @@ import android.content.Context import com.example.it_da.data.auth.SocialAuthClient import com.example.it_da.domain.model.SocialAuthAccount import com.example.it_da.domain.model.SocialAuthProvider +import javax.inject.Inject +import kotlin.jvm.JvmSuppressWildcards -class DefaultSocialAuthRepository( - private val socialAuthClients: List +class DefaultSocialAuthRepository @Inject constructor( + private val socialAuthClients: List<@JvmSuppressWildcards SocialAuthClient> ) : SocialAuthRepository { // Delegates authentication to the SDK client that owns the requested provider. override suspend fun authenticate( diff --git a/app/src/main/java/com/example/it_da/di/RepositoryModule.kt b/app/src/main/java/com/example/it_da/di/RepositoryModule.kt new file mode 100644 index 0000000..d59af03 --- /dev/null +++ b/app/src/main/java/com/example/it_da/di/RepositoryModule.kt @@ -0,0 +1,75 @@ +package com.example.it_da.di + +import com.example.it_da.data.local.AuthTokenLocalDataSource +import com.example.it_da.data.local.DefaultAuthTokenLocalDataSource +import com.example.it_da.data.repository.AuthSessionRepository +import com.example.it_da.data.repository.DefaultAuthSessionRepository +import com.example.it_da.data.repository.DefaultSocialAuthRepository +import com.example.it_da.data.repository.FakeHomeRepository +import com.example.it_da.data.repository.FakeNotificationRepository +import com.example.it_da.data.repository.FakeProjectCreateRepository +import com.example.it_da.data.repository.HomeRepository +import com.example.it_da.data.repository.NotificationRepository +import com.example.it_da.data.repository.ProjectCreateRepository +import com.example.it_da.data.repository.SocialAuthRepository +import com.example.it_da.data.store.DefaultNotificationStore +import com.example.it_da.data.store.DefaultProjectStore +import com.example.it_da.data.store.DefaultUserStore +import com.example.it_da.data.store.NotificationStore +import com.example.it_da.data.store.ProjectStore +import com.example.it_da.data.store.UserStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + @Binds + @Singleton + abstract fun bindAuthTokenLocalDataSource( + defaultAuthTokenLocalDataSource: DefaultAuthTokenLocalDataSource + ): AuthTokenLocalDataSource + + @Binds + @Singleton + abstract fun bindAuthSessionRepository( + defaultAuthSessionRepository: DefaultAuthSessionRepository + ): AuthSessionRepository + + @Binds + @Singleton + abstract fun bindUserStore(defaultUserStore: DefaultUserStore): UserStore + + @Binds + @Singleton + abstract fun bindProjectStore(defaultProjectStore: DefaultProjectStore): ProjectStore + + @Binds + @Singleton + abstract fun bindNotificationStore(defaultNotificationStore: DefaultNotificationStore): NotificationStore + + @Binds + @Singleton + abstract fun bindHomeRepository(fakeHomeRepository: FakeHomeRepository): HomeRepository + + @Binds + @Singleton + abstract fun bindNotificationRepository( + fakeNotificationRepository: FakeNotificationRepository + ): NotificationRepository + + @Binds + @Singleton + abstract fun bindProjectCreateRepository( + fakeProjectCreateRepository: FakeProjectCreateRepository + ): ProjectCreateRepository + + @Binds + @Singleton + abstract fun bindSocialAuthRepository( + defaultSocialAuthRepository: DefaultSocialAuthRepository + ): SocialAuthRepository +} diff --git a/app/src/main/java/com/example/it_da/di/SocialAuthModule.kt b/app/src/main/java/com/example/it_da/di/SocialAuthModule.kt new file mode 100644 index 0000000..35cf279 --- /dev/null +++ b/app/src/main/java/com/example/it_da/di/SocialAuthModule.kt @@ -0,0 +1,25 @@ +package com.example.it_da.di + +import com.example.it_da.BuildConfig +import com.example.it_da.data.auth.GoogleSocialAuthClient +import com.example.it_da.data.auth.KakaoSocialAuthClient +import com.example.it_da.data.auth.SocialAuthClient +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object SocialAuthModule { + // Provides SDK-specific clients behind the shared SocialAuthClient boundary. + @Provides + @Singleton + fun provideSocialAuthClients(): List { + return listOf( + GoogleSocialAuthClient(BuildConfig.GOOGLE_WEB_CLIENT_ID), + KakaoSocialAuthClient(BuildConfig.KAKAO_NATIVE_APP_KEY) + ) + } +} diff --git a/app/src/test/java/com/example/it_da/data/repository/DefaultAuthSessionRepositoryTest.kt b/app/src/test/java/com/example/it_da/data/repository/DefaultAuthSessionRepositoryTest.kt new file mode 100644 index 0000000..5c1c16c --- /dev/null +++ b/app/src/test/java/com/example/it_da/data/repository/DefaultAuthSessionRepositoryTest.kt @@ -0,0 +1,77 @@ +package com.example.it_da.data.repository + +import com.example.it_da.data.local.AuthTokenLocalDataSource +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DefaultAuthSessionRepositoryTest { + @Test + fun hasStoredTokenReturnsTrueWhenLocalTokenIsNotBlank() = runTest { + val repository = DefaultAuthSessionRepository( + FakeAuthTokenLocalDataSource(storedToken = "stored-token") + ) + + assertTrue(repository.hasStoredToken().getOrThrow()) + } + + @Test + fun hasStoredTokenReturnsFalseWhenLocalTokenIsBlank() = runTest { + val repository = DefaultAuthSessionRepository( + FakeAuthTokenLocalDataSource(storedToken = "") + ) + + assertFalse(repository.hasStoredToken().getOrThrow()) + } + + @Test + fun saveTemporaryTokenStoresNonBlankPlaceholderToken() = runTest { + val localDataSource = FakeAuthTokenLocalDataSource() + val repository = DefaultAuthSessionRepository(localDataSource) + + repository.saveTemporaryToken().getOrThrow() + + assertTrue(localDataSource.storedToken?.isNotBlank() == true) + } + + @Test + fun clearTokenRemovesStoredToken() = runTest { + val localDataSource = FakeAuthTokenLocalDataSource(storedToken = "stored-token") + val repository = DefaultAuthSessionRepository(localDataSource) + + repository.clearToken().getOrThrow() + + assertEquals(null, localDataSource.storedToken) + } + + @Test + fun localStorageFailureIsReturnedToCaller() = runTest { + val repository = DefaultAuthSessionRepository( + FakeAuthTokenLocalDataSource(readFailure = IllegalStateException("Read failed")) + ) + + assertEquals("Read failed", repository.hasStoredToken().exceptionOrNull()?.message) + } + + private class FakeAuthTokenLocalDataSource( + var storedToken: String? = null, + private val readFailure: Throwable? = null + ) : AuthTokenLocalDataSource { + override suspend fun getToken(): String? { + readFailure?.let { throwable -> + throw throwable + } + return storedToken + } + + override suspend fun saveToken(token: String) { + storedToken = token + } + + override suspend fun clearToken() { + storedToken = null + } + } +} From 407a9fd8b7a64a1d9bce56b9144e5b9e81506085 Mon Sep 17 00:00:00 2001 From: cnsvkf Date: Sat, 30 May 2026 21:36:26 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor(di):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20ViewModel=20Hilt=20=EC=A3=BC=EC=9E=85=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/it_da/ui/screen/home/HomeRoute.kt | 4 +- .../it_da/ui/screen/login/LoginRoute.kt | 13 +++- .../ui/screen/login/screen/LoginScreen.kt | 22 +++++-- .../ui/screen/login/state/LoginUiState.kt | 4 +- .../screen/login/viewmodel/LoginViewModel.kt | 41 ++++++++++-- .../login/viewmodel/LoginViewModelFactory.kt | 27 -------- .../login/viewmodel/LoginViewModelTest.kt | 65 +++++++++++++++++-- 7 files changed, 127 insertions(+), 49 deletions(-) delete mode 100644 app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelFactory.kt diff --git a/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt b/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt index d46e8f0..26eb564 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/home/HomeRoute.kt @@ -9,8 +9,8 @@ import com.example.it_da.ui.screen.home.viewmodel.HomeViewModel // Connects HomeViewModel state to the home screen and leaves future navigation targets as callbacks. @Composable fun HomeRoute( - onCreateProjectClick: () -> Unit, - onNotificationClick: () -> Unit, + onCreateProjectClick: () -> Unit = {}, + onNotificationClick: () -> Unit = {}, viewModel: HomeViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/LoginRoute.kt b/app/src/main/java/com/example/it_da/ui/screen/login/LoginRoute.kt index bcf68f9..bb99362 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/LoginRoute.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/LoginRoute.kt @@ -6,11 +6,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.example.it_da.ui.screen.login.screen.LoginScreen import com.example.it_da.ui.screen.login.state.LoginNavigationEffect import com.example.it_da.ui.screen.login.viewmodel.LoginViewModel -import com.example.it_da.ui.screen.login.viewmodel.LoginViewModelFactory // Connects login ViewModel state, social auth events, and navigation callbacks to the screen. @Composable @@ -18,10 +17,11 @@ fun LoginRoute( onSignUpClick: () -> Unit, onLoginSuccess: () -> Unit, onSocialSignUpSuccess: () -> Unit, - viewModel: LoginViewModel = viewModel(factory = LoginViewModelFactory()) + viewModel: LoginViewModel = hiltViewModel() ) { val context = LocalContext.current val uiState by viewModel.uiState.collectAsState() + val loginErrorMessage = uiState.loginErrorMessage val socialAuthErrorMessage = uiState.socialAuthErrorMessage LaunchedEffect(viewModel) { @@ -33,6 +33,13 @@ fun LoginRoute( } } + LaunchedEffect(loginErrorMessage) { + if (loginErrorMessage != null) { + Toast.makeText(context, loginErrorMessage, Toast.LENGTH_SHORT).show() + viewModel.clearLoginError() + } + } + LaunchedEffect(socialAuthErrorMessage) { if (socialAuthErrorMessage != null) { Toast.makeText(context, socialAuthErrorMessage, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/screen/LoginScreen.kt b/app/src/main/java/com/example/it_da/ui/screen/login/screen/LoginScreen.kt index 9729869..e209b9d 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/screen/LoginScreen.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/screen/LoginScreen.kt @@ -28,6 +28,14 @@ import com.example.it_da.ui.screen.login.component.SocialLoginButtonRow import com.example.it_da.ui.screen.login.state.LoginUiState import com.example.it_da.ui.theme.ITDATheme +private val LoginTopSpacing = 86.dp +private val LoginLogoIntroSpacing = 46.dp +private val LoginIntroInputSpacing = 69.dp +private val LoginInputButtonSpacing = 35.dp +private val LoginButtonSignUpSpacing = 15.dp +private val LoginSignUpSocialSpacing = 85.dp +private val LoginSocialGuideSpacing = 29.dp + // Assembles the complete login screen from focused UI components. @Composable fun LoginScreen( @@ -50,7 +58,7 @@ fun LoginScreen( .padding(horizontal = 25.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(86.dp)) + Spacer(modifier = Modifier.height(LoginTopSpacing)) Image( painter = painterResource(id = R.drawable.itda_logo), @@ -58,11 +66,11 @@ fun LoginScreen( modifier = Modifier.size(80.dp) ) - Spacer(modifier = Modifier.height(46.dp)) + Spacer(modifier = Modifier.height(LoginLogoIntroSpacing)) LoginIntroTextGroup() - Spacer(modifier = Modifier.height(69.dp)) + Spacer(modifier = Modifier.height(LoginIntroInputSpacing)) LoginInputGroup( id = uiState.id, @@ -71,20 +79,20 @@ fun LoginScreen( onPasswordChange = onPasswordChange ) - Spacer(modifier = Modifier.height(35.dp)) + Spacer(modifier = Modifier.height(LoginInputButtonSpacing)) LoginButton( enabled = uiState.isLoginEnabled, onClick = onLoginClick ) - Spacer(modifier = Modifier.height(15.dp)) + Spacer(modifier = Modifier.height(LoginButtonSignUpSpacing)) LoginSignUpGuide( onSignUpClick = onSignUpClick ) - Spacer(modifier = Modifier.height(85.dp)) + Spacer(modifier = Modifier.height(LoginSignUpSocialSpacing)) SocialLoginButtonRow( onAppleLoginClick = onAppleLoginClick, @@ -92,7 +100,7 @@ fun LoginScreen( onKakaoLoginClick = onKakaoLoginClick ) - Spacer(modifier = Modifier.height(29.dp)) + Spacer(modifier = Modifier.height(LoginSocialGuideSpacing)) LoginBottomGuideText() } diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/state/LoginUiState.kt b/app/src/main/java/com/example/it_da/ui/screen/login/state/LoginUiState.kt index 2a06878..44c5993 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/state/LoginUiState.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/state/LoginUiState.kt @@ -4,9 +4,11 @@ package com.example.it_da.ui.screen.login.state data class LoginUiState( val id: String = "", val password: String = "", + val isLoginLoading: Boolean = false, + val loginErrorMessage: String? = null, val isSocialAuthLoading: Boolean = false, val socialAuthErrorMessage: String? = null ) { val isLoginEnabled: Boolean - get() = id.isNotBlank() && password.isNotBlank() + get() = id.isNotBlank() && password.isNotBlank() && !isLoginLoading } diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModel.kt b/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModel.kt index c890337..26fb009 100644 --- a/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModel.kt @@ -4,10 +4,13 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.it_da.data.auth.SocialAuthSessionStore +import com.example.it_da.data.repository.AuthSessionRepository import com.example.it_da.data.repository.SocialAuthRepository import com.example.it_da.domain.model.SocialAuthProvider import com.example.it_da.ui.screen.login.state.LoginNavigationEffect import com.example.it_da.ui.screen.login.state.LoginUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -15,9 +18,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class LoginViewModel( +@HiltViewModel +class LoginViewModel @Inject constructor( private val socialAuthRepository: SocialAuthRepository, - private val socialAuthSessionStore: SocialAuthSessionStore = SocialAuthSessionStore.default + private val socialAuthSessionStore: SocialAuthSessionStore, + private val authSessionRepository: AuthSessionRepository ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState = _uiState.asStateFlow() @@ -39,14 +44,42 @@ class LoginViewModel( } } - // Emits the normal login navigation event until server login validation is connected. + // Saves a temporary local session and navigates home until server login validation is connected. fun onLoginClick() { if (!_uiState.value.isLoginEnabled) { return } viewModelScope.launch { - _navigationEffect.emit(LoginNavigationEffect.NavigateToHome) + _uiState.update { currentState -> + currentState.copy( + isLoginLoading = true, + loginErrorMessage = null + ) + } + + authSessionRepository.saveTemporaryToken() + .onSuccess { + _uiState.update { currentState -> + currentState.copy(isLoginLoading = false) + } + _navigationEffect.emit(LoginNavigationEffect.NavigateToHome) + } + .onFailure { throwable -> + _uiState.update { currentState -> + currentState.copy( + isLoginLoading = false, + loginErrorMessage = throwable.message ?: "로그인 상태 저장에 실패했습니다." + ) + } + } + } + } + + // Clears the normal login error after the UI has shown it to the user. + fun clearLoginError() { + _uiState.update { currentState -> + currentState.copy(loginErrorMessage = null) } } diff --git a/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelFactory.kt b/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelFactory.kt deleted file mode 100644 index 7cf7c85..0000000 --- a/app/src/main/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelFactory.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.it_da.ui.screen.login.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.it_da.BuildConfig -import com.example.it_da.data.auth.GoogleSocialAuthClient -import com.example.it_da.data.auth.KakaoSocialAuthClient -import com.example.it_da.data.repository.DefaultSocialAuthRepository - -class LoginViewModelFactory : ViewModelProvider.Factory { - // Creates LoginViewModel with provider-specific SDK clients hidden behind the repository. - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(LoginViewModel::class.java)) { - val socialAuthRepository = DefaultSocialAuthRepository( - socialAuthClients = listOf( - GoogleSocialAuthClient(BuildConfig.GOOGLE_WEB_CLIENT_ID), - KakaoSocialAuthClient(BuildConfig.KAKAO_NATIVE_APP_KEY) - ) - ) - - return LoginViewModel(socialAuthRepository) as T - } - - throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") - } -} diff --git a/app/src/test/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelTest.kt b/app/src/test/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelTest.kt index 112005a..f63bc93 100644 --- a/app/src/test/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelTest.kt +++ b/app/src/test/java/com/example/it_da/ui/screen/login/viewmodel/LoginViewModelTest.kt @@ -3,6 +3,7 @@ package com.example.it_da.ui.screen.login.viewmodel import android.content.Context import android.content.ContextWrapper import com.example.it_da.data.auth.SocialAuthSessionStore +import com.example.it_da.data.repository.AuthSessionRepository import com.example.it_da.data.repository.SocialAuthRepository import com.example.it_da.domain.model.SocialAuthAccount import com.example.it_da.domain.model.SocialAuthProvider @@ -41,7 +42,7 @@ class LoginViewModelTest { ) val repository = DeferredSocialAuthRepository() val sessionStore = SocialAuthSessionStore() - val viewModel = LoginViewModel(repository, sessionStore) + val viewModel = LoginViewModel(repository, sessionStore, FakeAuthSessionRepository()) val navigationEffect = async { viewModel.navigationEffect.first() } @@ -77,7 +78,7 @@ class LoginViewModelTest { ) val repository = DeferredSocialAuthRepository() val sessionStore = SocialAuthSessionStore() - val viewModel = LoginViewModel(repository, sessionStore) + val viewModel = LoginViewModel(repository, sessionStore, FakeAuthSessionRepository()) val navigationEffect = async { viewModel.navigationEffect.first() } @@ -102,7 +103,7 @@ class LoginViewModelTest { ) { val repository = DeferredSocialAuthRepository() val sessionStore = SocialAuthSessionStore() - val viewModel = LoginViewModel(repository, sessionStore) + val viewModel = LoginViewModel(repository, sessionStore, FakeAuthSessionRepository()) viewModel.onGoogleSignUpClick(context) runCurrent() @@ -119,7 +120,11 @@ class LoginViewModelTest { mainDispatcherRule.testDispatcher ) { val repository = DeferredSocialAuthRepository() - val viewModel = LoginViewModel(repository, SocialAuthSessionStore()) + val viewModel = LoginViewModel( + repository, + SocialAuthSessionStore(), + FakeAuthSessionRepository() + ) viewModel.onAppleSignUpClick() @@ -133,7 +138,12 @@ class LoginViewModelTest { mainDispatcherRule.testDispatcher ) { val repository = DeferredSocialAuthRepository() - val viewModel = LoginViewModel(repository, SocialAuthSessionStore()) + val authSessionRepository = FakeAuthSessionRepository() + val viewModel = LoginViewModel( + repository, + SocialAuthSessionStore(), + authSessionRepository + ) val navigationEffect = async { viewModel.navigationEffect.first() } @@ -148,6 +158,30 @@ class LoginViewModelTest { navigationEffect.await() ) assertNull(repository.requestedProvider) + assertEquals(1, authSessionRepository.saveRequestCount) + } + + @Test + fun normalLoginStaysOnLoginAndShowsErrorWhenSessionSaveFails() = runTest( + mainDispatcherRule.testDispatcher + ) { + val authSessionRepository = FakeAuthSessionRepository( + saveResult = Result.failure(IllegalStateException("Session save failed")) + ) + val viewModel = LoginViewModel( + DeferredSocialAuthRepository(), + SocialAuthSessionStore(), + authSessionRepository + ) + + viewModel.onIdChange("itda-user") + viewModel.onPasswordChange("password") + viewModel.onLoginClick() + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoginLoading) + assertEquals("Session save failed", viewModel.uiState.value.loginErrorMessage) + assertEquals(1, authSessionRepository.saveRequestCount) } private class DeferredSocialAuthRepository : SocialAuthRepository { @@ -169,4 +203,25 @@ class LoginViewModelTest { result.complete(authResult) } } + + private class FakeAuthSessionRepository( + private val saveResult: Result = Result.success(Unit) + ) : AuthSessionRepository { + var saveRequestCount: Int = 0 + private set + + override suspend fun hasStoredToken(): Result { + return Result.success(false) + } + + // Records the temporary token request and returns the configured storage result. + override suspend fun saveTemporaryToken(): Result { + saveRequestCount += 1 + return saveResult + } + + override suspend fun clearToken(): Result { + return Result.success(Unit) + } + } }