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 f0345d4..c701a06 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
@@ -12,6 +12,10 @@ val ArrowRightIcon: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.ic_arrow_right)
+val ArrowDownIcon: ImageVector
+ @Composable
+ get() = ImageVector.vectorResource(id = R.drawable.ic_arrow_down)
+
val CalendarIcon: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.ic_calendar)
@@ -36,6 +40,10 @@ val NotificationIcon: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.ic_notification)
+val NotificationSmallIcon: ImageVector
+ @Composable
+ get() = ImageVector.vectorResource(id = R.drawable.ic_notification_small)
+
val SearchIcon: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.ic_search)
@@ -118,4 +126,12 @@ val CircleUnselectedIcon: ImageVector
val CircleCheckedIcon: ImageVector
@Composable
- get() = ImageVector.vectorResource(id = R.drawable.ic_circle_checked)
\ No newline at end of file
+ get() = ImageVector.vectorResource(id = R.drawable.ic_circle_checked)
+
+val SearchCancelIcon: ImageVector
+ @Composable
+ get() = ImageVector.vectorResource(id = R.drawable.ic_search_cancel)
+
+val CloseIcon: ImageVector
+ @Composable
+ get() = ImageVector.vectorResource(id = R.drawable.ic_close)
\ No newline at end of file
diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_arrow_down.xml b/core/presentation/designsystem/src/main/res/drawable/ic_arrow_down.xml
new file mode 100644
index 0000000..9bd207e
--- /dev/null
+++ b/core/presentation/designsystem/src/main/res/drawable/ic_arrow_down.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_close.xml b/core/presentation/designsystem/src/main/res/drawable/ic_close.xml
new file mode 100644
index 0000000..04e9227
--- /dev/null
+++ b/core/presentation/designsystem/src/main/res/drawable/ic_close.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_notification_small.xml b/core/presentation/designsystem/src/main/res/drawable/ic_notification_small.xml
new file mode 100644
index 0000000..9a15d60
--- /dev/null
+++ b/core/presentation/designsystem/src/main/res/drawable/ic_notification_small.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_search_cancel.xml b/core/presentation/designsystem/src/main/res/drawable/ic_search_cancel.xml
new file mode 100644
index 0000000..4d195db
--- /dev/null
+++ b/core/presentation/designsystem/src/main/res/drawable/ic_search_cancel.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/core/presentation/ui/src/main/res/values/strings.xml b/core/presentation/ui/src/main/res/values/strings.xml
index 4e7fed0..0a843a6 100644
--- a/core/presentation/ui/src/main/res/values/strings.xml
+++ b/core/presentation/ui/src/main/res/values/strings.xml
@@ -35,4 +35,15 @@
읽음
삭제
취소
+
+
+ 검색어를 입력해 주세요
+ 닫기
+ 최근 검색
+ 전체 삭제
+ 지역
+ 제품
+ 키워드 알림을 추가했어요.
+ 키워드 알림을 더이상 추가할 수 없어요.
+ (마이페이지 > 내 정보 > 키워드 알림)에서 확인해 주세요.
\ 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
index e549e22..3c95b13 100644
--- a/notification/presentation/src/main/java/com/hyunjung/notification/presentation/NotificationScreen.kt
+++ b/notification/presentation/src/main/java/com/hyunjung/notification/presentation/NotificationScreen.kt
@@ -36,10 +36,10 @@ 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.notification.presentation.component.AlertType
import com.hyunjung.auth.presentation.LocalCherrydanContentColor
-import com.hyunjung.auth.presentation.NotificationActiveToggleItem
-import com.hyunjung.auth.presentation.NotificationToggleItem
+import com.hyunjung.notification.presentation.component.NotificationActiveToggleItem
+import com.hyunjung.notification.presentation.component.NotificationToggleItem
import com.hyunjung.core.presentation.designsystem.BackIcon
import com.hyunjung.core.presentation.designsystem.CherrydanColors
import com.hyunjung.core.presentation.designsystem.CherrydanTheme
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
index 17818a7..95f1bf9 100644
--- 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
@@ -1,4 +1,4 @@
-package com.hyunjung.auth.presentation
+package com.hyunjung.notification.presentation.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
diff --git a/search/data/.gitignore b/search/data/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/search/data/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/search/data/build.gradle.kts b/search/data/build.gradle.kts
new file mode 100644
index 0000000..85d90ce
--- /dev/null
+++ b/search/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.search.data"
+}
+
+dependencies {
+ implementation(libs.bundles.koin)
+
+ implementation(projects.search.domain)
+ implementation(projects.core.domain)
+ implementation(projects.core.data)
+}
\ No newline at end of file
diff --git a/search/data/consumer-rules.pro b/search/data/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/search/data/proguard-rules.pro b/search/data/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/search/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/search/data/src/androidTest/java/com/hyunjung/search/data/ExampleInstrumentedTest.kt b/search/data/src/androidTest/java/com/hyunjung/search/data/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..0f2284d
--- /dev/null
+++ b/search/data/src/androidTest/java/com/hyunjung/search/data/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.hyunjung.search.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.search.data.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/search/data/src/main/AndroidManifest.xml b/search/data/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5918e6
--- /dev/null
+++ b/search/data/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/search/data/src/test/java/com/hyunjung/search/data/ExampleUnitTest.kt b/search/data/src/test/java/com/hyunjung/search/data/ExampleUnitTest.kt
new file mode 100644
index 0000000..b97b7c5
--- /dev/null
+++ b/search/data/src/test/java/com/hyunjung/search/data/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.hyunjung.search.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/search/domain/.gitignore b/search/domain/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/search/domain/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/search/domain/build.gradle.kts b/search/domain/build.gradle.kts
new file mode 100644
index 0000000..d7c49a4
--- /dev/null
+++ b/search/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/search/domain/src/main/java/com/hyunjung/search/domain/MyClass.kt b/search/domain/src/main/java/com/hyunjung/search/domain/MyClass.kt
new file mode 100644
index 0000000..4bc7a84
--- /dev/null
+++ b/search/domain/src/main/java/com/hyunjung/search/domain/MyClass.kt
@@ -0,0 +1,4 @@
+package com.hyunjung.search.domain
+
+class MyClass {
+}
\ No newline at end of file
diff --git a/search/presentation/.gitignore b/search/presentation/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/search/presentation/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/search/presentation/build.gradle.kts b/search/presentation/build.gradle.kts
new file mode 100644
index 0000000..be1bb61
--- /dev/null
+++ b/search/presentation/build.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+ alias(libs.plugins.hyunjung.cherrydan.android.feature.ui)
+}
+
+android {
+ namespace = "com.hyunjung.search.presentation"
+}
+
+dependencies {
+ implementation(libs.androidx.navigation.compose)
+
+ implementation(projects.core.domain)
+ implementation(projects.search.domain)
+}
\ No newline at end of file
diff --git a/search/presentation/consumer-rules.pro b/search/presentation/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/search/presentation/proguard-rules.pro b/search/presentation/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/search/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/search/presentation/src/androidTest/java/com/hyunjung/search/presentation/ExampleInstrumentedTest.kt b/search/presentation/src/androidTest/java/com/hyunjung/search/presentation/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..497b390
--- /dev/null
+++ b/search/presentation/src/androidTest/java/com/hyunjung/search/presentation/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.hyunjung.search.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.search.presentation.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/search/presentation/src/main/AndroidManifest.xml b/search/presentation/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5918e6
--- /dev/null
+++ b/search/presentation/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/search/presentation/src/main/java/com/hyunjung/search/presentation/SearchScreen.kt b/search/presentation/src/main/java/com/hyunjung/search/presentation/SearchScreen.kt
new file mode 100644
index 0000000..7ad88a8
--- /dev/null
+++ b/search/presentation/src/main/java/com/hyunjung/search/presentation/SearchScreen.kt
@@ -0,0 +1,235 @@
+package com.hyunjung.search.presentation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+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.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.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+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.ClockIcon
+import com.hyunjung.core.presentation.designsystem.CloseIcon
+import com.hyunjung.core.presentation.designsystem.SearchIcon
+import com.hyunjung.search.presentation.component.SearchToolbar
+
+@Composable
+fun SearchScreen(modifier: Modifier = Modifier) {
+ // todo : viewmodel 사용 해서 만들면 됩니다.
+}
+
+@Composable
+fun SearchContent(
+ searchQuery: String,
+ recentSearchQueries: List,
+ suggestions: List,
+ onSearchQueryChanged: (String) -> Unit,
+ onSearchTriggered: (String) -> Unit,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(CherrydanColors.White)
+ ) {
+ SearchToolbar(
+ searchQuery = searchQuery,
+ onSearchQueryChanged = onSearchQueryChanged,
+ onSearchTriggered = onSearchTriggered,
+ onBackClick = onBackClick,
+ modifier = modifier
+ )
+ if (searchQuery.isEmpty()) {
+ RecentSearch(recentSearchQueries = recentSearchQueries)
+ } else {
+ SuggestedSearchList(
+ searchQuery = searchQuery,
+ suggestions = suggestions,
+ onSuggestionClick = { query ->
+ onSearchQueryChanged(query)
+ onSearchTriggered(query)
+ },
+ modifier = modifier
+ )
+ }
+ }
+}
+
+@Composable
+private fun RecentSearch(
+ recentSearchQueries: List,
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize(1f)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "최근 검색",
+ style = CherrydanTypography.Main5_B,
+ color = CherrydanColors.Black
+ )
+ Text(
+ text = "전체 삭제",
+ style = CherrydanTypography.Main5_R,
+ color = CherrydanColors.Gray4
+ )
+ }
+ }
+ items(recentSearchQueries) { query ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = ClockIcon,
+ contentDescription = null,
+ tint = CherrydanColors.Gray4,
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = query,
+ style = CherrydanTypography.Main5_R,
+ color = CherrydanColors.Black,
+ modifier = Modifier.weight(1f)
+ )
+ Icon(
+ imageVector = CloseIcon,
+ contentDescription = null,
+ tint = CherrydanColors.Black,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SuggestedSearchList(
+ searchQuery: String,
+ suggestions: List,
+ onSuggestionClick: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp, vertical = 22.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ items(suggestions) { suggestion ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = { onSuggestionClick(suggestion) }),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = SearchIcon,
+ contentDescription = null,
+ tint = CherrydanColors.Gray4,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = highlightQuery(
+ text = suggestion,
+ query = searchQuery,
+ highlightColor = CherrydanColors.MainPink2,
+ ),
+ style = CherrydanTypography.Main5_R,
+ color = CherrydanColors.Black,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+}
+
+
+@Preview(showBackground = true, name = "Empty Query")
+@Composable
+private fun SearchScreenPreview() {
+ val recentSearchQueries = listOf(
+ "강남 맛집",
+ "효소 체험",
+ "맛있는 피자 분당점",
+ )
+ CherrydanTheme {
+ SearchContent(
+ searchQuery = "",
+ recentSearchQueries = recentSearchQueries,
+ suggestions = emptyList(),
+ onSearchQueryChanged = {},
+ onSearchTriggered = {},
+ onBackClick = {}
+ )
+ }
+}
+
+@Preview(showBackground = true, name = "With Query")
+@Composable
+private fun SearchScreenWithQueryPreview() {
+ val recentSearchQueries = emptyList()
+ val suggestions = listOf(
+ "강남 맛집",
+ "강남역 카페",
+ "강남 데이트 코스",
+ "강남신세계"
+ )
+
+ CherrydanTheme {
+ SearchContent(
+ searchQuery = "강남",
+ recentSearchQueries = recentSearchQueries,
+ suggestions = suggestions,
+ onSearchQueryChanged = {},
+ onSearchTriggered = {},
+ onBackClick = {}
+ )
+ }
+}
+
+@Composable
+fun highlightQuery(text: String, query: String, highlightColor: Color): AnnotatedString {
+ if (query.isBlank()) return AnnotatedString(text)
+
+ val startIndex = text.indexOf(query, ignoreCase = true)
+ if (startIndex == -1) return AnnotatedString(text)
+
+ val endIndex = startIndex + query.length
+ return buildAnnotatedString {
+ append(text.substring(0, startIndex))
+ withStyle(SpanStyle(color = highlightColor)) {
+ append(text.substring(startIndex, endIndex))
+ }
+ append(text.substring(endIndex))
+ }
+}
\ No newline at end of file
diff --git a/search/presentation/src/main/java/com/hyunjung/search/presentation/component/CherrydanSearchTextField.kt b/search/presentation/src/main/java/com/hyunjung/search/presentation/component/CherrydanSearchTextField.kt
new file mode 100644
index 0000000..b3dbb77
--- /dev/null
+++ b/search/presentation/src/main/java/com/hyunjung/search/presentation/component/CherrydanSearchTextField.kt
@@ -0,0 +1,143 @@
+package com.hyunjung.search.presentation.component
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.selection.LocalTextSelectionColors
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TextFieldColors
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.takeOrElse
+import androidx.compose.ui.semantics.error
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.hyunjung.core.presentation.designsystem.CherrydanTypography
+import com.hyunjung.search.presentation.component.CherrydanTextFieldDefaults.cursorColor
+import com.hyunjung.search.presentation.component.CherrydanTextFieldDefaults.defaultErrorSemantics
+import com.hyunjung.search.presentation.component.CherrydanTextFieldDefaults.textColor
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CherrydanSearchTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ readOnly: Boolean = false,
+ textStyle: TextStyle = CherrydanTypography.Main5_R,
+ label: @Composable (() -> Unit)? = null,
+ placeholder: @Composable (() -> Unit)? = null,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ prefix: @Composable (() -> Unit)? = null,
+ suffix: @Composable (() -> Unit)? = null,
+ supportingText: @Composable (() -> Unit)? = null,
+ isError: Boolean = false,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ singleLine: Boolean = false,
+ maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
+ minLines: Int = 1,
+ interactionSource: MutableInteractionSource? = null,
+ shape: Shape = TextFieldDefaults.shape,
+ colors: TextFieldColors = TextFieldDefaults.colors()
+) {
+ @Suppress("NAME_SHADOWING")
+ val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
+ val textColor =
+ textStyle.color.takeOrElse {
+ val focused = interactionSource.collectIsFocusedAsState().value
+ colors.textColor(enabled, isError, focused)
+ }
+ val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
+
+ CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
+ BasicTextField(
+ value = value,
+ modifier =
+ modifier
+ .defaultErrorSemantics(isError)
+ .defaultMinSize(
+ minWidth = CherrydanTextFieldDefaults.MinWidth,
+ minHeight = CherrydanTextFieldDefaults.MinHeight
+ ),
+ onValueChange = onValueChange,
+ enabled = enabled,
+ readOnly = readOnly,
+ textStyle = mergedTextStyle,
+ cursorBrush = SolidColor(colors.cursorColor(isError)),
+ visualTransformation = visualTransformation,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ interactionSource = interactionSource,
+ singleLine = singleLine,
+ maxLines = maxLines,
+ minLines = minLines,
+ decorationBox =
+ @Composable { innerTextField ->
+ TextFieldDefaults.DecorationBox(
+ value = value,
+ visualTransformation = visualTransformation,
+ innerTextField = innerTextField,
+ placeholder = placeholder,
+ label = label,
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon,
+ prefix = prefix,
+ suffix = suffix,
+ supportingText = supportingText,
+ shape = shape,
+ singleLine = singleLine,
+ enabled = enabled,
+ isError = isError,
+ interactionSource = interactionSource,
+ colors = colors,
+ contentPadding = PaddingValues(8.dp)
+ )
+ }
+ )
+ }
+}
+
+object CherrydanTextFieldDefaults {
+
+ const val DEFAULT_ERROR_MESSAGE = "입력값이 올바르지 않습니다."
+ val MinWidth: Dp = 280.dp
+ val MinHeight: Dp = 40.dp
+
+ fun Modifier.defaultErrorSemantics(isError: Boolean): Modifier =
+ if (isError) semantics { error(DEFAULT_ERROR_MESSAGE) } else this
+
+ @Stable
+ internal fun TextFieldColors.textColor(
+ enabled: Boolean,
+ isError: Boolean,
+ focused: Boolean,
+ ): Color =
+ when {
+ !enabled -> disabledTextColor
+ isError -> errorTextColor
+ focused -> focusedTextColor
+ else -> unfocusedTextColor
+ }
+
+ @Stable
+ internal fun TextFieldColors.cursorColor(isError: Boolean): Color =
+ if (isError) errorCursorColor else cursorColor
+}
\ No newline at end of file
diff --git a/search/presentation/src/main/java/com/hyunjung/search/presentation/component/SearchToolBar.kt b/search/presentation/src/main/java/com/hyunjung/search/presentation/component/SearchToolBar.kt
new file mode 100644
index 0000000..82b1efe
--- /dev/null
+++ b/search/presentation/src/main/java/com/hyunjung/search/presentation/component/SearchToolBar.kt
@@ -0,0 +1,166 @@
+package com.hyunjung.search.presentation.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+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.SearchCancelIcon
+
+@Composable
+fun SearchToolbar(
+ searchQuery: String,
+ onSearchQueryChanged: (String) -> Unit,
+ onSearchTriggered: (String) -> Unit,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ IconButton(onClick = { onBackClick() }) {
+ Icon(
+ imageVector = BackIcon,
+ contentDescription = null,
+ )
+ }
+ SearchTextField(
+ onSearchQueryChanged = onSearchQueryChanged,
+ onSearchTriggered = onSearchTriggered,
+ searchQuery = searchQuery,
+ )
+ Text(
+ text = "닫기",
+ style = CherrydanTypography.Main5_R,
+ color = CherrydanColors.Black,
+ modifier = Modifier
+ .padding(start = 12.dp, end = 16.dp)
+ .clickable(onClick = onBackClick)
+ )
+ }
+}
+
+@Composable
+private fun RowScope.SearchTextField(
+ searchQuery: String,
+ onSearchQueryChanged: (String) -> Unit,
+ onSearchTriggered: (String) -> Unit,
+) {
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ val onSearchExplicitlyTriggered = {
+ keyboardController?.hide()
+ onSearchTriggered(searchQuery)
+ }
+
+ CherrydanSearchTextField(
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ focusedContainerColor = CherrydanColors.Gray1,
+ unfocusedContainerColor = CherrydanColors.Gray1,
+ ),
+ trailingIcon = {
+ if (searchQuery.isNotEmpty()) {
+ IconButton(
+ onClick = {
+ onSearchQueryChanged("")
+ },
+ ) {
+ Icon(
+ imageVector = SearchCancelIcon,
+ contentDescription = null,
+ tint = Color.Unspecified
+ )
+ }
+ }
+ },
+ placeholder = {
+ Text(
+ text = "검색어를 입력해 주세요.",
+ style = CherrydanTypography.Main5_R,
+ color = CherrydanColors.Gray4,
+ )
+ },
+ onValueChange = {
+ if ("\n" !in it) onSearchQueryChanged(it)
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(40.dp)
+ .focusRequester(focusRequester)
+ .onKeyEvent {
+ if (it.key == Key.Enter) {
+ if (searchQuery.isBlank()) return@onKeyEvent false
+ onSearchExplicitlyTriggered()
+ true
+ } else {
+ false
+ }
+ }
+ .testTag("searchTextField"),
+ shape = RoundedCornerShape(4.dp),
+ value = searchQuery,
+ keyboardOptions = KeyboardOptions(
+ imeAction = ImeAction.Search,
+ ),
+ keyboardActions = KeyboardActions(
+ onSearch = {
+ if (searchQuery.isBlank()) return@KeyboardActions
+ onSearchExplicitlyTriggered()
+ },
+ ),
+ maxLines = 1,
+ singleLine = true,
+ )
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+}
+
+@Composable
+@Preview(showBackground = true)
+fun SearchToolbarPreview() {
+ CherrydanTheme {
+ val searchQuery = remember { mutableStateOf("") }
+
+ SearchToolbar(
+ searchQuery = searchQuery.value,
+ onSearchQueryChanged = { searchQuery.value = it },
+ onSearchTriggered = {},
+ onBackClick = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/search/presentation/src/test/java/com/hyunjung/search/presentation/ExampleUnitTest.kt b/search/presentation/src/test/java/com/hyunjung/search/presentation/ExampleUnitTest.kt
new file mode 100644
index 0000000..4e98569
--- /dev/null
+++ b/search/presentation/src/test/java/com/hyunjung/search/presentation/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.hyunjung.search.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 440f18b..cef6780 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -38,4 +38,7 @@ include(":home:domain")
include(":home:presentation")
include(":notification:data")
include(":notification:domain")
-include(":notification:presentation")
\ No newline at end of file
+include(":notification:presentation")
+include(":search:presentation")
+include(":search:domain")
+include(":search:data")