diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/Icons.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/Icons.kt index 1ba1dec..f0345d4 100644 --- a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/Icons.kt +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/Icons.kt @@ -4,6 +4,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource +val BackIcon: ImageVector + @Composable + get() = ImageVector.vectorResource(id = R.drawable.ic_back) + +val ArrowRightIcon: ImageVector + @Composable + get() = ImageVector.vectorResource(id = R.drawable.ic_arrow_right) + val CalendarIcon: ImageVector @Composable get() = ImageVector.vectorResource(id = R.drawable.ic_calendar) @@ -98,4 +106,16 @@ val CherryUnselectedIcon: ImageVector val CherrySelectedIcon: ImageVector @Composable - get() = ImageVector.vectorResource(id = R.drawable.ic_cherry_selected) \ No newline at end of file + get() = ImageVector.vectorResource(id = R.drawable.ic_cherry_selected) + +val CircleSelectedIcon: ImageVector + @Composable + get() = ImageVector.vectorResource(id = R.drawable.ic_circle_selected) + +val CircleUnselectedIcon: ImageVector + @Composable + get() = ImageVector.vectorResource(id = R.drawable.ic_circle_unselected) + +val CircleCheckedIcon: ImageVector + @Composable + get() = ImageVector.vectorResource(id = R.drawable.ic_circle_checked) \ No newline at end of file diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanFixedTabRow.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanFixedTabRow.kt new file mode 100644 index 0000000..2dd65cd --- /dev/null +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanFixedTabRow.kt @@ -0,0 +1,184 @@ +package com.hyunjung.core.presentation.designsystem.component + +import com.hyunjung.core.presentation.designsystem.component.CherrydanFixedTabRowDefaults.fixedTabIndicatorOffset +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachIndexed +import com.hyunjung.auth.presentation.LocalCherrydanContentColor +import com.hyunjung.core.presentation.designsystem.CherrydanColors +import com.hyunjung.core.presentation.designsystem.CherrydanTheme +import com.hyunjung.core.presentation.designsystem.CherrydanTypography + +@Composable +fun CherrydanFixedTabRow( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + containerColor: Color = CherrydanFixedTabRowDefaults.ContainerColor, + contentColor: Color = CherrydanFixedTabRowDefaults.ContentColor, + indicator: @Composable (tabPositions: List) -> Unit = + @Composable { tabPositions -> + CherrydanFixedTabRowDefaults.Indicator( + Modifier.fixedTabIndicatorOffset(tabPositions[selectedTabIndex]) + ) + }, + tabs: @Composable () -> Unit +) { + Surface( + modifier = modifier, + color = containerColor, + contentColor = contentColor + ) { + SubcomposeLayout( + modifier = Modifier + .fillMaxWidth() + .selectableGroup() + .clipToBounds() + ) { constraints -> + val tabMeasurables = subcompose(FixedTabSlots.Tabs, tabs) + val tabCount = tabMeasurables.size + val tabWidth = if (tabCount > 0) constraints.maxWidth / tabCount else 0 + + val tabConstraints = constraints.copy( + minWidth = tabWidth, + maxWidth = tabWidth + ) + + val tabPlaceables = tabMeasurables.map { measurable -> + measurable.measure(tabConstraints) + } + + val layoutHeight = tabPlaceables.maxOfOrNull { it.height } ?: 0 + val layoutWidth = constraints.maxWidth + + layout(layoutWidth, layoutHeight) { + val tabPositions = mutableListOf() + + tabPlaceables.fastForEachIndexed { index, placeable -> + val left = index * tabWidth + placeable.placeRelative(left, 0) + + tabPositions.add( + CherrydanTabPosition( + left = left.toDp(), + width = tabWidth.toDp(), + contentWidth = (tabWidth - HorizontalTextPadding.roundToPx() * 2).toDp() + ) + ) + } + + subcompose(FixedTabSlots.Indicator) { + indicator(tabPositions) + }.fastForEach { measurable -> + val indicatorPlaceable = measurable.measure( + Constraints.fixed(layoutWidth, layoutHeight) + ) + indicatorPlaceable.placeRelative(0, 0) + } + } + } + } +} + +private enum class FixedTabSlots { + Tabs, + Indicator +} + +@Preview(showBackground = true) +@Composable +private fun CherrydanFixedTabsPreview() { + val tabs = listOf("활동", "맞춤형") + val selectedIndex = 0 + + CherrydanTheme { + CherrydanFixedTabRow(selectedTabIndex = selectedIndex) { + tabs.forEachIndexed { index, label -> + val selected = index == selectedIndex + CherrydanTab( + selected = selected, + onClick = {} + ) { + Text( + text = label, + color = LocalCherrydanContentColor.current, + style = if (selected) CherrydanTypography.Main3_B else CherrydanTypography.Main3_R + ) + } + } + } + } +} + +object CherrydanFixedTabRowDefaults { + val ContainerColor: Color = Color.Transparent + val ContentColor: Color = CherrydanColors.MainPink3 + + @Composable + fun Indicator( + modifier: Modifier = Modifier, + height: Dp = 2.dp, + color: Color = ContentColor + ) { + Box( + modifier + .fillMaxWidth() + .height(height) + .background(color = color) + ) + } + + fun Modifier.fixedTabIndicatorOffset(currentTabPosition: CherrydanTabPosition): Modifier = + composed( + inspectorInfo = debugInspectorInfo { + name = "fixedTabIndicatorOffset" + value = currentTabPosition + } + ) { + val currentTabWidth by animateDpAsState( + targetValue = currentTabPosition.width, + animationSpec = FixedTabRowIndicatorSpec, + label = "indicator-width" + ) + val indicatorOffset by animateDpAsState( + targetValue = currentTabPosition.left, + animationSpec = FixedTabRowIndicatorSpec, + label = "indicator-offset" + ) + fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset { IntOffset(indicatorOffset.roundToPx(), 0) } + .width(currentTabWidth) + } +} + +private val FixedTabRowIndicatorSpec: AnimationSpec = + tween(durationMillis = 250, easing = FastOutSlowInEasing) + +private val HorizontalTextPadding: Dp = 16.dp \ No newline at end of file diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBar.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBar.kt index 63b7c90..5c8459a 100644 --- a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBar.kt +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBar.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.hyunjung.auth.presentation.LocalCherrydanContentColor +import com.hyunjung.core.presentation.designsystem.BackIcon import com.hyunjung.core.presentation.designsystem.CherrydanColors import com.hyunjung.core.presentation.designsystem.CherrydanTypography @@ -100,7 +101,7 @@ private fun TopAppBarBase( .align(if (centeredTitle) Alignment.Center else Alignment.CenterStart) .padding( start = if (!centeredTitle && navigationIcon != null) { - CherrydanTopAppBarDefaults.HorizontalPadding + CherrydanTopAppBarDefaults.NavigationButtonSize + CherrydanTopAppBarDefaults.NavigationButtonSize } else 0.dp ) ) { @@ -194,7 +195,7 @@ private fun CherrydanTopAppBarPreview() { title = "Title", navigationIcon = { TopBarIconButton( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + imageVector = BackIcon, contentDescription = "Back", onClick = {} ) @@ -219,7 +220,7 @@ private fun CherrydanTopAppBarCenteredPreview() { centeredTitle = true, navigationIcon = { TopBarIconButton( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + imageVector = BackIcon, contentDescription = "Back", onClick = {} ) diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_arrow_right.xml b/core/presentation/designsystem/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000..488f610 --- /dev/null +++ b/core/presentation/designsystem/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,11 @@ + + + diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_back.xml b/core/presentation/designsystem/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..d09f44d --- /dev/null +++ b/core/presentation/designsystem/src/main/res/drawable/ic_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_circle_checked.xml b/core/presentation/designsystem/src/main/res/drawable/ic_circle_checked.xml new file mode 100644 index 0000000..7567317 --- /dev/null +++ b/core/presentation/designsystem/src/main/res/drawable/ic_circle_checked.xml @@ -0,0 +1,16 @@ + + + + diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_circle_selected.xml b/core/presentation/designsystem/src/main/res/drawable/ic_circle_selected.xml new file mode 100644 index 0000000..facdda1 --- /dev/null +++ b/core/presentation/designsystem/src/main/res/drawable/ic_circle_selected.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_circle_unselected.xml b/core/presentation/designsystem/src/main/res/drawable/ic_circle_unselected.xml new file mode 100644 index 0000000..cf1f800 --- /dev/null +++ b/core/presentation/designsystem/src/main/res/drawable/ic_circle_unselected.xml @@ -0,0 +1,11 @@ + + + diff --git a/core/presentation/ui/src/main/res/values/strings.xml b/core/presentation/ui/src/main/res/values/strings.xml index 4d99583..4e7fed0 100644 --- a/core/presentation/ui/src/main/res/values/strings.xml +++ b/core/presentation/ui/src/main/res/values/strings.xml @@ -26,4 +26,13 @@ 내 체험단 마이페이지 + + + 알림 + 활동 + 맞춤형 + 모두 선택 + 읽음 + 삭제 + 취소 \ No newline at end of file diff --git a/home/presentation/src/main/java/com/hyunjung/home/presentation/home/HomeScreen.kt b/home/presentation/src/main/java/com/hyunjung/home/presentation/home/HomeScreen.kt index 1f0002f..da7911a 100644 --- a/home/presentation/src/main/java/com/hyunjung/home/presentation/home/HomeScreen.kt +++ b/home/presentation/src/main/java/com/hyunjung/home/presentation/home/HomeScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -46,6 +47,7 @@ import com.hyunjung.core.presentation.designsystem.component.CherrydanTab import com.hyunjung.core.presentation.designsystem.component.CherrydanTabPosition import com.hyunjung.core.presentation.designsystem.component.CherrydanTopAppBar import com.hyunjung.core.presentation.designsystem.component.TopBarIconButton +import com.hyunjung.core.presentation.ui.R @Composable fun HomeScreenRoot(modifier: Modifier = Modifier) { @@ -87,8 +89,7 @@ fun HomeContent( modifier = modifier, topBar = { CherrydanTopAppBar( - // todo : StringResource를 사용하여 문자열을 관리하는 것이 좋습니다. - title = "체리단", + title = stringResource(id = R.string.splash_title), actions = { TopBarIconButton( imageVector = NotificationIcon, diff --git a/notification/data/.gitignore b/notification/data/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/notification/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/notification/data/build.gradle.kts b/notification/data/build.gradle.kts new file mode 100644 index 0000000..3a6370d --- /dev/null +++ b/notification/data/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.hyunjung.cherrydan.android.library) + alias(libs.plugins.hyunjung.cherrydan.jvm.ktor) +} + +android { + namespace = "com.hyunjung.notification.data" +} + +dependencies { + implementation(libs.bundles.koin) + + implementation(projects.notification.domain) + implementation(projects.core.domain) + implementation(projects.core.data) +} \ No newline at end of file diff --git a/notification/data/consumer-rules.pro b/notification/data/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/notification/data/proguard-rules.pro b/notification/data/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/notification/data/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/notification/data/src/androidTest/java/com/hyunjung/notification/data/ExampleInstrumentedTest.kt b/notification/data/src/androidTest/java/com/hyunjung/notification/data/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..86327c9 --- /dev/null +++ b/notification/data/src/androidTest/java/com/hyunjung/notification/data/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.hyunjung.notification.data + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.hyunjung.notification.data.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/notification/data/src/main/AndroidManifest.xml b/notification/data/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/notification/data/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/notification/data/src/test/java/com/hyunjung/notification/data/ExampleUnitTest.kt b/notification/data/src/test/java/com/hyunjung/notification/data/ExampleUnitTest.kt new file mode 100644 index 0000000..9d273d7 --- /dev/null +++ b/notification/data/src/test/java/com/hyunjung/notification/data/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.hyunjung.notification.data + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/notification/domain/.gitignore b/notification/domain/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/notification/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/notification/domain/build.gradle.kts b/notification/domain/build.gradle.kts new file mode 100644 index 0000000..d7c49a4 --- /dev/null +++ b/notification/domain/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.hyunjung.cherrydan.jvm.library) +} + +dependencies { + implementation(projects.core.domain) +} \ No newline at end of file diff --git a/notification/presentation/.gitignore b/notification/presentation/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/notification/presentation/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/notification/presentation/build.gradle.kts b/notification/presentation/build.gradle.kts new file mode 100644 index 0000000..869aa5c --- /dev/null +++ b/notification/presentation/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.hyunjung.cherrydan.android.feature.ui) +} + +android { + namespace = "com.hyunjung.notification.presentation" +} + +dependencies { + implementation(libs.androidx.navigation.compose) + + implementation(projects.core.domain) + implementation(projects.notification.domain) +} \ No newline at end of file diff --git a/notification/presentation/consumer-rules.pro b/notification/presentation/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/notification/presentation/proguard-rules.pro b/notification/presentation/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/notification/presentation/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/notification/presentation/src/androidTest/java/com/hyunjung/notification/presentation/ExampleInstrumentedTest.kt b/notification/presentation/src/androidTest/java/com/hyunjung/notification/presentation/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..64bb798 --- /dev/null +++ b/notification/presentation/src/androidTest/java/com/hyunjung/notification/presentation/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.hyunjung.notification.presentation + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.hyunjung.notification.presentation.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/notification/presentation/src/main/AndroidManifest.xml b/notification/presentation/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/notification/presentation/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/notification/presentation/src/main/java/com/hyunjung/notification/presentation/NotificationScreen.kt b/notification/presentation/src/main/java/com/hyunjung/notification/presentation/NotificationScreen.kt new file mode 100644 index 0000000..e549e22 --- /dev/null +++ b/notification/presentation/src/main/java/com/hyunjung/notification/presentation/NotificationScreen.kt @@ -0,0 +1,400 @@ +package com.hyunjung.notification.presentation + +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.hyunjung.auth.presentation.AlertType +import com.hyunjung.auth.presentation.LocalCherrydanContentColor +import com.hyunjung.auth.presentation.NotificationActiveToggleItem +import com.hyunjung.auth.presentation.NotificationToggleItem +import com.hyunjung.core.presentation.designsystem.BackIcon +import com.hyunjung.core.presentation.designsystem.CherrydanColors +import com.hyunjung.core.presentation.designsystem.CherrydanTheme +import com.hyunjung.core.presentation.designsystem.CherrydanTypography +import com.hyunjung.core.presentation.designsystem.CircleUnselectedIcon +import com.hyunjung.core.presentation.designsystem.TrashIcon +import com.hyunjung.core.presentation.designsystem.component.CherrydanFixedTabRow +import com.hyunjung.core.presentation.designsystem.component.CherrydanTab +import com.hyunjung.core.presentation.designsystem.component.CherrydanTopAppBar +import com.hyunjung.core.presentation.designsystem.component.TopBarIconButton + +@Composable +fun NotificationScreen( + onBackPressed: () -> Unit = {}, + onDeletePressed: () -> Unit = {} +) { + var selectedTabIndex by remember { mutableIntStateOf(0) } + val notificationItems = remember { getSampleNotifications() } + val isInPreview = LocalInspectionMode.current + + Column( + modifier = Modifier + .fillMaxSize() + .background(CherrydanColors.White) + ) { + if (isInPreview) { + CherrydanTopAppBar( + title = "알림", + navigationIcon = { + TopBarIconButton( + imageVector = BackIcon, + contentDescription = "Back", + onClick = {} + ) + }, + actions = { + TopBarIconButton( + imageVector = TrashIcon, + contentDescription = "Delete", + onClick = {} + ) + } + ) + } else { + CherrydanTopAppBar( + title = stringResource(id = com.hyunjung.core.presentation.ui.R.string.notification_title), + navigationIcon = { + TopBarIconButton( + imageVector = BackIcon, + contentDescription = "Back", + onClick = {} + ) + }, + actions = { + TopBarIconButton( + imageVector = TrashIcon, + contentDescription = "Delete", + onClick = {} + ) + } + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .background(color = CherrydanColors.White) + ) { + + CherrydanFixedTabRow( + selectedTabIndex = selectedTabIndex + ) { + val tabs = if (isInPreview) { + listOf("활동", "맞춤형") + } else { + listOf( + stringResource(com.hyunjung.core.presentation.ui.R.string.notification_activity), + stringResource(com.hyunjung.core.presentation.ui.R.string.notification_keyword) + ) + } + + tabs.forEachIndexed { index, title -> + val selected = index == selectedTabIndex + CherrydanTab( + selected = selected, + onClick = { selectedTabIndex = index } + ) { + Text( + text = title, + color = if (selected) CherrydanColors.MainPink3 else CherrydanColors.Gray4, + style = if (selected) { + CherrydanTypography.Main3_B + } else { + CherrydanTypography.Main3_R + }, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + color = CherrydanColors.PointBeige + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CircleUnselectedIcon, + contentDescription = "All Select", + tint = LocalCherrydanContentColor.current, + modifier = Modifier + .size(24.dp) + ) + + Spacer(modifier = Modifier.width(2.dp)) + + Text( + text = stringResource(id = com.hyunjung.core.presentation.ui.R.string.notification_all_select), + style = CherrydanTypography.Main4_R, + color = CherrydanColors.Black, + modifier = Modifier.weight(1f) + ) + + Text( + text = stringResource(id = com.hyunjung.core.presentation.ui.R.string.notification_read), + style = CherrydanTypography.Main4_R, + color = CherrydanColors.Black + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 알림 목록 + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + items(notificationItems) { item -> + if (selectedTabIndex == 0) { // 활동 탭 + // NotificationActiveToggleItem 사용 + val annotatedContent = createAnnotatedString(item.content) + + NotificationActiveToggleItem( + alertType = AlertType.VISITED, + content = annotatedContent, + time = System.currentTimeMillis(), + selected = !item.isRead, + showBadge = item.hasHighPriority, + onClick = { /* TODO: 알림 클릭 시 동작 */ }, + showDivider = item.id != (notificationItems.lastOrNull()?.id ?: 0), + modifier = Modifier.padding(vertical = 4.dp) + ) + } else { // 맞춤형 탭 + // 일반 NotificationToggleItem 사용 + NotificationToggleItem( + selected = !item.isRead, + showBadge = item.hasHighPriority, + onClick = { /* TODO: 알림 클릭 시 동작 */ }, + showDivider = item.id != (notificationItems.lastOrNull()?.id ?: 0), + paddingValues = PaddingValues(vertical = 8.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = item.title, + style = CherrydanTypography.Main5_B, + color = CherrydanColors.Gray4 + ) + + if (item.content.isNotEmpty()) { + Text( + text = item.content, + style = CherrydanTypography.Main5_R, + color = CherrydanColors.Gray4 + ) + } + + if (item.date.isNotEmpty()) { + Text( + text = item.date, + style = CherrydanTypography.Main6_R, + color = CherrydanColors.Gray4 + ) + } + } + } + } + } + } + } +} + +@Composable +private fun createAnnotatedString(content: String) = buildAnnotatedString { + append(content) + + val boldPart = "D-3 [목요일] 리자이드 양주점" + val startIndex = content.indexOf(boldPart) + if (startIndex >= 0) { + addStyle( + style = SpanStyle(fontWeight = FontWeight.Bold), + start = startIndex, + end = startIndex + boldPart.length + ) + } +} + +@Composable +private fun NotificationItemProduction( + item: NotificationItemData, + modifier: Modifier = Modifier +) { + val isInPreview = LocalInspectionMode.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) { + // 알림 내용 + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = item.title, + fontSize = 16.sp, + fontWeight = if (item.isRead) FontWeight.Normal else FontWeight.Medium, + color = Color.Black + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (item.hasHighPriority) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(CherrydanColors.MainPink3) + ) + } + Text( + text = if (isInPreview) "읽음" else "읽음", // 프리뷰에서는 하드코딩 + fontSize = 12.sp, + color = Color(0xFF9E9E9E) + ) + } + } + + if (item.content.isNotEmpty()) { + Text( + text = item.content, + fontSize = 14.sp, + color = Color(0xFF757575), + lineHeight = 20.sp + ) + } + + if (item.date.isNotEmpty()) { + Text( + text = item.date, + fontSize = 12.sp, + color = Color(0xFFBDBDBD) + ) + } + } + } + + // 구분선 + if (item.id != 5) { + HorizontalDivider( + modifier = Modifier.padding(start = 36.dp), + color = Color(0xFFF5F5F5), + thickness = 1.dp + ) + } +} + +data class NotificationItemData( + val id: Int, + val title: String, + val content: String, + val date: String, + val isRead: Boolean = false, + val hasHighPriority: Boolean = false +) + +private fun getSampleNotifications(): List { + return listOf( + NotificationItemData( + id = 1, + title = "모든 선택", + content = "집가고 싶다. 집가고 싶다. 집가고 싶다. 집가고 싶다.", + date = "2025.05.05", + isRead = false + ), + NotificationItemData( + id = 2, + title = "병원방문", + content = "D-3 [목요일] 리자이드 양주점, 피드&월스 방문리의 3일 남았습니다.", + date = "2025.05.05", + isRead = false, + hasHighPriority = true + ), + NotificationItemData( + id = 3, + title = "병원방문", + content = "D-3 [목요일] 리자이드 양주점, 피드&월스 방문리의 3일 남았습니다.", + date = "2025.05.05", + isRead = false + ), + NotificationItemData( + id = 4, + title = "병원방문", + content = "D-3 [목요일] 리자이드 양주점, 피드&월스 방문리의 3일 남았습니다.", + date = "2025.05.05", + isRead = false + ), + NotificationItemData( + id = 5, + title = "병원방문", + content = "D-3 [목요일] 리자이드 양주점, 피드&월스 방문리의 3일 남았습니다.", + date = "2025.05.05", + isRead = false, + hasHighPriority = true + ) + ) +} + +@Preview(showBackground = true) +@Composable +private fun NotificationScreenPreview() { + CherrydanTheme { + NotificationScreen() + } +} \ No newline at end of file diff --git a/notification/presentation/src/main/java/com/hyunjung/notification/presentation/component/NotificationToggleItem.kt b/notification/presentation/src/main/java/com/hyunjung/notification/presentation/component/NotificationToggleItem.kt new file mode 100644 index 0000000..17818a7 --- /dev/null +++ b/notification/presentation/src/main/java/com/hyunjung/notification/presentation/component/NotificationToggleItem.kt @@ -0,0 +1,285 @@ +package com.hyunjung.auth.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.hyunjung.core.presentation.designsystem.ArrowRightIcon +import com.hyunjung.core.presentation.designsystem.CherrydanColors +import com.hyunjung.core.presentation.designsystem.CherrydanTheme +import com.hyunjung.core.presentation.designsystem.CherrydanTypography +import com.hyunjung.core.presentation.designsystem.CircleSelectedIcon +import com.hyunjung.core.presentation.designsystem.CircleUnselectedIcon +import java.text.SimpleDateFormat +import java.util.Locale + +private val ContentPadding = 4.dp + +enum class AlertType( + val label: String, +) { + VISITED("방문알림"), +} + +@Composable +fun NotificationCustomToggleItem( + content: AnnotatedString, + selected: Boolean, + showBadge: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + showDivider: Boolean = true, +) { + Row( + modifier = modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + NotificationToggleItem( + selected = selected, + showBadge = showBadge, + onClick = onClick, + modifier = Modifier.weight(1f), + showDivider = showDivider, + verticalAlignment = Alignment.CenterVertically, + paddingValues = PaddingValues(top = 8.dp, bottom = 16.dp) + ) { + Text( + text = content, + style = CherrydanTypography.Main5_R + ) + } + Icon( + imageVector = ArrowRightIcon, + contentDescription = null, + ) + } +} + +@Composable +fun NotificationActiveToggleItem( + alertType: AlertType, + content: AnnotatedString, + time: Long, + selected: Boolean, + showBadge: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + showDivider: Boolean = false, +) { + val formatter = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) + + NotificationToggleItem( + selected = selected, + showBadge = showBadge, + onClick = onClick, + modifier = modifier, + showDivider = showDivider, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = alertType.label, + style = CherrydanTypography.Main5_B, + color = CherrydanColors.Gray5 + ) + Text( + text = content, + style = CherrydanTypography.Main5_R + ) + Text( + text = formatter.format(time), + style = CherrydanTypography.Main5_R, + color = CherrydanColors.Gray4 + ) + } + } +} + +@Composable +fun NotificationToggleItem( + selected: Boolean, + showBadge: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + showDivider: Boolean, + verticalAlignment: Alignment.Vertical = Alignment.Top, + paddingValues: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit, +) { + val frontIcon = if (selected) CircleSelectedIcon else CircleUnselectedIcon + Box( + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .drawBehind { + if (showDivider) { + drawLine( + color = CherrydanColors.Gray2, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1.dp.toPx() + ) + } + } + .padding(paddingValues), + ) { + Row(verticalAlignment = verticalAlignment) { + Icon( + imageVector = frontIcon, + tint = Color.Unspecified, + contentDescription = null, + modifier = Modifier + .size(24.dp) + ) + Box( + modifier = Modifier + .weight(1f) + .padding( + start = ContentPadding, + end = ContentPadding, + bottom = 8.dp + ) + ) { + content() + } + } + if (showBadge) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .size(8.dp) + .background(color = CherrydanColors.MainPink2, shape = CircleShape) + .clip(CircleShape) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun NotificationActiveToggleItemPreview() { + val fullText = "D-3 [양주] 리치마트 양주점_피드&릴스 방문일이 3일 남았습니다." + val boldText = "D-3 [양주] 리치마트 양주점_피드" + + val startIndex = fullText.indexOf(boldText) + val endIndex = startIndex + boldText.length + + val annotatedString = buildAnnotatedString { + append(fullText) + addStyle( + style = CherrydanTypography.Main5_B.toSpanStyle(), + start = startIndex, + end = endIndex + ) + } + + CherrydanTheme { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + NotificationActiveToggleItem( + alertType = AlertType.VISITED, + content = annotatedString, + time = 1724892093000, + selected = true, + showBadge = true, + onClick = {}, + showDivider = true + ) + NotificationActiveToggleItem( + alertType = AlertType.VISITED, + content = annotatedString, + time = 1724892093000, + selected = false, + showBadge = false, + onClick = {}, + showDivider = true + ) + + NotificationActiveToggleItem( + alertType = AlertType.VISITED, + content = annotatedString, + time = 1724892093000, + selected = false, + showBadge = true, + onClick = {}, + showDivider = false + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun NotificationCustomToggleItemPreview() { + val fullText = "딸기케이크 캠페인이 23건 등록되었어요. 지금 바로 확인해 보세요." + val annotatedString = buildAnnotatedString { + append(fullText) + + // 딸기케이크 스타일 적용 + val cakeText = "딸기케이크" + val cakeStart = fullText.indexOf(cakeText) + val cakeEnd = cakeStart + cakeText.length + addStyle( + style = SpanStyle(fontWeight = FontWeight.Bold), + start = cakeStart, + end = cakeEnd + ) + + // 23건 스타일 적용 + val countText = "23건" + val countStart = fullText.indexOf(countText) + val countEnd = countStart + countText.length + addStyle( + style = SpanStyle( + fontWeight = FontWeight.Bold, + color = CherrydanColors.MainPink2 + ), + start = countStart, + end = countEnd + ) + } + + CherrydanTheme { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + NotificationCustomToggleItem( + content = annotatedString, + selected = true, + showBadge = true, + onClick = {}, + ) + NotificationCustomToggleItem( + content = annotatedString, + selected = false, + showBadge = true, + onClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/notification/presentation/src/main/java/com/hyunjung/notification/presentation/model/NotificationUiState.kt b/notification/presentation/src/main/java/com/hyunjung/notification/presentation/model/NotificationUiState.kt new file mode 100644 index 0000000..783f3a5 --- /dev/null +++ b/notification/presentation/src/main/java/com/hyunjung/notification/presentation/model/NotificationUiState.kt @@ -0,0 +1,12 @@ +package com.hyunjung.notification.presentation.model + +import com.hyunjung.notification.presentation.viewmodel.NotificationItem +import com.hyunjung.notification.presentation.viewmodel.NotificationTabType + +data class NotificationUiState( + val notifications: List = emptyList(), + val selectedTab: NotificationTabType = NotificationTabType.ALL, + val selectedFilter: String = "전체", + val isLoading: Boolean = false, + val showMenu: Boolean = false +) \ No newline at end of file diff --git a/notification/presentation/src/main/java/com/hyunjung/notification/presentation/viewmodel/NotificationViewModel.kt b/notification/presentation/src/main/java/com/hyunjung/notification/presentation/viewmodel/NotificationViewModel.kt new file mode 100644 index 0000000..96fd40d --- /dev/null +++ b/notification/presentation/src/main/java/com/hyunjung/notification/presentation/viewmodel/NotificationViewModel.kt @@ -0,0 +1,24 @@ +package com.hyunjung.notification.presentation.viewmodel + +import androidx.compose.ui.graphics.Color +import com.hyunjung.core.presentation.designsystem.CherrydanColors + +data class NotificationItem( + val id: Int, + val title: String, + val subtitle: String, + val date: String, + val isRead: Boolean = false, + val priority: NotificationPriority = NotificationPriority.NORMAL, + val content: String +) + +enum class NotificationPriority(val color: Color) { + HIGH(CherrydanColors.MainPink3), + NORMAL(CherrydanColors.Gray4) +} + +enum class NotificationTabType(val title: String) { + ALL("활동"), + READ("맞춤형") +} \ No newline at end of file diff --git a/notification/presentation/src/test/java/com/hyunjung/notification/presentation/ExampleUnitTest.kt b/notification/presentation/src/test/java/com/hyunjung/notification/presentation/ExampleUnitTest.kt new file mode 100644 index 0000000..6a4126b --- /dev/null +++ b/notification/presentation/src/test/java/com/hyunjung/notification/presentation/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.hyunjung.notification.presentation + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 553f5de..440f18b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,4 +35,7 @@ include(":core:database") include(":core:network") include(":home:data") include(":home:domain") -include(":home:presentation") \ No newline at end of file +include(":home:presentation") +include(":notification:data") +include(":notification:domain") +include(":notification:presentation") \ No newline at end of file