Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package com.texthip.thip.ui.common.bottomsheet

// com.texthip.thip.ui.common.bottomsheet.CustomBottomSheet.kt

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.texthip.thip.ui.theme.ThipTheme
import com.texthip.thip.ui.theme.ThipTheme.colors
import kotlinx.coroutines.launch

/**
* Displays a custom animated bottom sheet with drag-to-dismiss and outside-tap dismissal.
*
* The sheet slides up from the bottom of the screen and can be dismissed by dragging downward past a threshold or by tapping outside the sheet area. The content of the sheet is provided via a composable slot scoped to `ColumnScope`.
*
* @param onDismiss Called when the sheet is dismissed by user interaction.
* @param content Composable content to display inside the bottom sheet.
*/
@Composable
fun CustomBottomSheet(
onDismiss: () -> Unit,
// 핵심: ColumnScope로 slot 전달!
content: @Composable ColumnScope.() -> Unit
) {
val scope = rememberCoroutineScope()
val animatableOffset = remember { Animatable(300f) }
var offsetY by remember { mutableFloatStateOf(0f) }
var isDismissing by remember { mutableStateOf(false) }

// 등장 애니메이션
LaunchedEffect(Unit) {
animatableOffset.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 300)
)
}

// 바깥 클릭 감지
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (!isDismissing) {
isDismissing = true
scope.launch {
animatableOffset.animateTo(300f, tween(300))
onDismiss()
}
}
}
.zIndex(1f)
)

// BottomSheet 본체
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(2f),
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.offset(y = (offsetY + animatableOffset.value).dp)
.background(
color = colors.DarkGrey,
shape = RoundedCornerShape(topEnd = 12.dp, topStart = 12.dp)
)
.pointerInput(Unit) {
detectVerticalDragGestures(
onVerticalDrag = { _, dragAmount ->
if (dragAmount > 0) {
offsetY += dragAmount / 2
}
},
onDragEnd = {
if (offsetY > 100f && !isDismissing) {
isDismissing = true
scope.launch {
animatableOffset.animateTo(300f, tween(300))
onDismiss()
}
} else {
offsetY = 0f
}
}
)
}
.clickable(enabled = true) {} // 내부 클릭 먹히게
) {
Column(modifier = Modifier.fillMaxWidth()) {
content() // <--- 이 부분이 핵심! slot에 원하는 Compose UI를 전달
}
}
}
}


/**
* Displays a preview of the CustomBottomSheet composable with sample content and dismissal behavior.
*
* Shows a main content area with a bottom sheet overlay that can be dismissed by tapping a button or outside the sheet.
*/
@Preview()
@Composable
fun PreviewCustomBottomSheet() {
var showSheet by remember { mutableStateOf(true) }

ThipTheme {
Box(Modifier.fillMaxSize()) {
// 배경 컨텐츠 예시
Text(
text = "Main Content Area",
color = Color.White,
modifier = Modifier
.align(Alignment.Center)
)

if (showSheet) {
CustomBottomSheet(
onDismiss = { showSheet = false }
) {
Text(
"바텀 시트 예시",
color = Color.White,
modifier = Modifier.padding(bottom = 16.dp)
)
Button(
onClick = { showSheet = false },
modifier = Modifier.fillMaxWidth()
) {
Text("닫기", color = Color.Black)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.texthip.thip.ui.common.buttons

import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

/**
* Displays a horizontal row of selectable genre chips, centered within the available width.
*
* Each chip represents a genre from the provided list. The chip at `selectedIndex` is visually highlighted.
* Selecting a chip invokes the `onSelect` callback with the index of the selected genre.
*
* @param modifier Modifier applied to the spacer between chips. Defaults to a width of 4.dp.
* @param genres List of genre names to display as chips.
* @param selectedIndex Index of the currently selected genre.
* @param onSelect Callback invoked with the index of the selected genre when a chip is clicked.
*/
@Composable
fun GenreChipRow(
modifier: Modifier = Modifier.width(4.dp),
genres: List<String>,
selectedIndex: Int,
onSelect: (Int) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
genres.forEachIndexed { idx, genre ->
OptionChipButton(
text = genre,
isFilled = true,
isSelected = selectedIndex == idx,
onClick = { onSelect(idx) }
)
if (idx < genres.size - 1) {
Spacer(modifier = modifier)
}
}
}
}

/**
* Displays a preview of the GenreChipRow composable with sample genres and the first genre selected.
*/
@Preview(showBackground = true, backgroundColor = 0xFF000000, widthDp = 360)
@Composable
fun PreviewGenreChipRow() {
GenreChipRow(
genres = listOf("문학", "과학·IT", "사회과학", "인문학", "예술"),
selectedIndex = 0,
onSelect = {}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,29 +25,28 @@ import com.texthip.thip.ui.theme.ThipTheme.colors
import com.texthip.thip.ui.theme.ThipTheme.typography


/**
* Displays a horizontal card with a book cover image and title, styled for search results.
*
* The card is clickable and fills the available width. If an image resource is provided, it is shown as the book cover; otherwise, a default image is used. The title is displayed next to the image.
*
* @param title The book title to display.
* @param imageRes Optional image resource ID for the book cover. Defaults to a sample image if not specified.
* @param onClick Lambda invoked when the card is clicked.
*/
@Composable
fun CardBookSearch(
modifier: Modifier = Modifier,
number: Int,
title: String,
imageRes: Int? = R.drawable.bookcover_sample, // 기본 이미지 리소스
onClick: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 8.dp),
.clickable { onClick() },
verticalAlignment = Alignment.CenterVertically
) {
// 넘버
Text(
text = "$number.",
style = typography.menu_m500_s16_h24,
color = colors.White,
modifier = Modifier.padding(end = 12.dp)
)

// 이미지
Box(
modifier = Modifier
Expand Down Expand Up @@ -75,6 +74,9 @@ fun CardBookSearch(
}
}

/**
* Displays a preview of the CardBookSearch composable with sample data for UI development.
*/
@Preview
@Composable
fun CardBookSearchPreview() {
Expand All @@ -84,7 +86,6 @@ fun CardBookSearchPreview() {
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CardBookSearch(
number = 1,
title = "단 한번의 삶"
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.texthip.thip.ui.common.forms

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.texthip.thip.R
import com.texthip.thip.ui.theme.ThipTheme.colors
import com.texthip.thip.ui.theme.ThipTheme.typography

/**
* Displays a styled search text field for book queries with a hint and search action.
*
* The field maintains its own input state, displays a placeholder hint, and provides clear and search icons.
* Clicking the clear icon resets the input, while the search icon invokes the provided callback with the current text.
*
* @param hint The placeholder text shown when the field is empty.
* @param onSearch Callback invoked with the current input text when the search icon is clicked.
*/
@Composable
fun SearchBookTextField(
modifier: Modifier = Modifier,
hint: String,
onSearch: (String) -> Unit = {}
) {
var text by rememberSaveable { mutableStateOf("") }
val myStyle = typography.menu_r400_s14_h24.copy(lineHeight = 14.sp)

Box(
modifier = modifier.height(48.dp)
) {
OutlinedTextField(
value = text,
onValueChange = {
text = it
},
placeholder = {
Text(
text = hint,
color = colors.Grey02,
style = myStyle
)
},
textStyle = myStyle,
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(12.dp),
colors = TextFieldDefaults.colors(
focusedTextColor = colors.White,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedContainerColor = colors.DarkGrey02,
unfocusedContainerColor = colors.DarkGrey02,
cursorColor = colors.NeonGreen
),
trailingIcon = {
Row(
horizontalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(id = R.drawable.ic_x_circle_grey),
contentDescription = "Clear text",
modifier = Modifier
.clickable { text = "" },
tint = Color.Unspecified
)

Spacer(Modifier.width(20.dp))
Icon(
painter = painterResource(id = R.drawable.ic_search),
contentDescription = "Search",
modifier = Modifier
.clickable { onSearch(text) },
tint = colors.White
)
Spacer(Modifier.width(8.dp))
}
},
singleLine = true
)
}
}

/**
* Displays a preview of the SearchBookTextField composable with a sample hint for IDE visualization.
*/
@Preview()
@Composable
private fun SearchBookTextFieldPreview() {
SearchBookTextField(
hint = "책 제목, 저자검색",
onSearch = { /* 검색 실행 */ }
)
}
Loading