diff --git a/app/src/main/java/com/texthip/thip/ui/common/bottomsheet/CustomBottomSheet.kt b/app/src/main/java/com/texthip/thip/ui/common/bottomsheet/CustomBottomSheet.kt new file mode 100644 index 00000000..109cf73d --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/common/bottomsheet/CustomBottomSheet.kt @@ -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) + } + } + } + } + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipRow.kt b/app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipRow.kt new file mode 100644 index 00000000..bceae581 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipRow.kt @@ -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, + 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 = {} + ) +} diff --git a/app/src/main/java/com/texthip/thip/ui/common/cards/CardBookSearch.kt b/app/src/main/java/com/texthip/thip/ui/common/cards/CardBookSearch.kt index 1bc7fa03..b178cf69 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/cards/CardBookSearch.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/cards/CardBookSearch.kt @@ -25,10 +25,18 @@ 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 = {} @@ -36,18 +44,9 @@ fun CardBookSearch( 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 @@ -75,6 +74,9 @@ fun CardBookSearch( } } +/** + * Displays a preview of the CardBookSearch composable with sample data for UI development. + */ @Preview @Composable fun CardBookSearchPreview() { @@ -84,7 +86,6 @@ fun CardBookSearchPreview() { verticalArrangement = Arrangement.spacedBy(8.dp) ) { CardBookSearch( - number = 1, title = "단 한번의 삶" ) } diff --git a/app/src/main/java/com/texthip/thip/ui/common/forms/SearchBookTextField.kt b/app/src/main/java/com/texthip/thip/ui/common/forms/SearchBookTextField.kt new file mode 100644 index 00000000..1f4f3e37 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/common/forms/SearchBookTextField.kt @@ -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 = { /* 검색 실행 */ } + ) +} diff --git a/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt b/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt index 533cfcea..578586bf 100644 --- a/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/forms/WarningTextField.kt @@ -2,11 +2,12 @@ package com.texthip.thip.ui.common.forms import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -14,12 +15,13 @@ 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.remember 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.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -27,92 +29,139 @@ import com.texthip.thip.R import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +/** + * Displays an outlined text field with optional input restrictions and a warning message. + * + * The field supports input filtering for numeric-only values, enforces a maximum length, and allows customization of keyboard type. A clear icon appears when the field is not empty, enabling users to clear the input. If `showWarning` is true, a warning message is displayed below the field. + * + * @param value The current text input value. + * @param onValueChange Callback invoked with the updated input value after filtering. + * @param modifier Modifier for styling and layout. + * @param hint Placeholder text shown when the input is empty. + * @param warningMessage The warning message displayed below the field when `showWarning` is true. + * @param showWarning Whether to display the warning message and highlight the field in red. + * @param maxLength Maximum allowed length for the input. + * @param isNumberOnly If true, restricts input to digits only. + * @param keyboardType The keyboard type to use for input. + */ @Composable fun WarningTextField( + value: String, + onValueChange: (String) -> Unit, modifier: Modifier = Modifier, hint: String, warningMessage: String = "경고 메시지를 입력해주세요.", - showWarning: Boolean + showWarning: Boolean = false, + maxLength: Int = Int.MAX_VALUE, + isNumberOnly: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Text ) { - var text by rememberSaveable { mutableStateOf("") } val myStyle = typography.menu_r400_s14_h24.copy(lineHeight = 14.sp) - - Column { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - placeholder = { - Text( - text = hint, - color = colors.Grey02, - style = myStyle - ) - }, - textStyle = myStyle, - modifier = modifier.size(width = 320.dp, height = 48.dp), - shape = RoundedCornerShape(12.dp), - colors = TextFieldDefaults.colors( - focusedTextColor = colors.White, - focusedIndicatorColor = if (showWarning) colors.Red else Color.Transparent, - unfocusedIndicatorColor = if (showWarning) colors.Red else Color.Transparent, - focusedContainerColor = colors.Black, - unfocusedContainerColor = colors.Black, - cursorColor = colors.NeonGreen - ), - trailingIcon = { - if (text.isNotEmpty()) { - Icon( - painter = painterResource(id = R.drawable.ic_x_circle_white), - contentDescription = "Clear text", - modifier = Modifier.clickable { text = "" }, - tint = Color.Unspecified - ) - } else { - Icon( - painter = painterResource(id = R.drawable.ic_x_circle), - contentDescription = "Clear text" - ) - } - }, - singleLine = true - ) - - if (showWarning) { - Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = value, + onValueChange = { input -> + var filtered = input + if (isNumberOnly) filtered = filtered.filter { it.isDigit() } + if (filtered.length > maxLength) filtered = filtered.take(maxLength) + onValueChange(filtered) + }, + placeholder = { Text( - text = warningMessage, - color = colors.Red, - style = typography.info_r400_s12.copy(lineHeight = 12.sp) + text = hint, + color = colors.Grey02, + style = myStyle ) - } + }, + textStyle = myStyle, + modifier = modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = TextFieldDefaults.colors( + focusedTextColor = colors.White, + focusedIndicatorColor = if (showWarning) colors.Red else Color.Transparent, + unfocusedIndicatorColor = if (showWarning) colors.Red else Color.Transparent, + focusedContainerColor = colors.DarkGrey50, + unfocusedContainerColor = colors.DarkGrey50, + cursorColor = colors.NeonGreen + ), + trailingIcon = { + if (value.isNotEmpty()) { + Icon( + painter = painterResource(id = R.drawable.ic_x_circle_white), + contentDescription = "Clear text", + modifier = Modifier.clickable { onValueChange("") }, + tint = Color.Unspecified + ) + } else { + Icon( + painter = painterResource(id = R.drawable.ic_x_circle), + contentDescription = "Clear text" + ) + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType) + ) + if (showWarning) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = warningMessage, + color = colors.Red, + style = typography.info_r400_s12.copy(lineHeight = 12.sp) + ) } } + +/** + * Displays a preview of the WarningTextField composable with an initially empty value and numeric password input. + * + * Shows a warning message if the input is non-empty and less than 4 digits. Configured for a 4-digit numeric password with a number password keyboard type. + */ @Composable @Preview(showBackground = true, backgroundColor = 0xFF000000, widthDp = 360, heightDp = 200) fun WarningTextFieldPreviewEmpty() { + var password by remember { mutableStateOf("") } + Box( modifier = Modifier.size(width = 360.dp, height = 200.dp), contentAlignment = Alignment.Center ) { WarningTextField( - hint = "인풋 텍스트", - showWarning = true, - warningMessage = "경고 메시지를 입력해주세요." + value = password, + onValueChange = { password = it }, + hint = "4자리 숫자로 입장 비밀번호를 설정", + showWarning = password.isNotEmpty() && password.length < 4, + warningMessage = "4자리 숫자를 입력해주세요.", + maxLength = 4, + isNumberOnly = true, + keyboardType = KeyboardType.NumberPassword ) } } +/** + * Displays a preview of the WarningTextField composable configured for numeric password input without showing a warning message. + */ @Composable @Preview(showBackground = true, backgroundColor = 0xFF000000, widthDp = 360, heightDp = 200) fun WarningTextFieldPreviewNormal() { + var password by remember { mutableStateOf("") } + Box( modifier = Modifier.size(width = 360.dp, height = 200.dp), contentAlignment = Alignment.Center ) { WarningTextField( - hint = "인풋 텍스트", - showWarning = false + value = password, + onValueChange = { password = it }, + hint = "4자리 숫자로 입장 비밀번호를 설정", + showWarning = false, + warningMessage = "4자리 숫자를 입력해주세요.", + maxLength = 4, + isNumberOnly = true, + keyboardType = KeyboardType.NumberPassword ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookListWithScrollbar.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookListWithScrollbar.kt new file mode 100644 index 00000000..1f19506a --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookListWithScrollbar.kt @@ -0,0 +1,85 @@ +package com.texthip.thip.ui.group.makeroom.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.texthip.thip.R +import com.texthip.thip.ui.common.cards.CardBookSearch +import com.texthip.thip.ui.common.modal.drawVerticalScrollbar +import com.texthip.thip.ui.group.makeroom.mock.BookData +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors + + +/** + * Displays a vertically scrollable list of books with a custom vertical scrollbar. + * + * Each book is shown as a clickable card with its title and image. Selecting a book triggers the provided callback. + * + * @param books The list of books to display. + * @param onBookClick Callback invoked when a book is selected. + */ +@Composable +fun GroupBookListWithScrollbar( + books: List, + onBookClick: (BookData) -> Unit +) { + val scrollState = rememberScrollState() + + Box( + Modifier + .fillMaxWidth() + .height(320.dp) + ) { + Column( + Modifier + .verticalScroll(scrollState) + .drawVerticalScrollbar(scrollState) + .fillMaxWidth() + ) { + books.forEach { book -> + CardBookSearch( + title = book.title, + imageRes = book.imageRes, + onClick = { onBookClick(book) } + ) + + Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(color = colors.Grey02) + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } +} + +/** + * Displays a preview of the GroupBookListWithScrollbar composable with sample book data. + * + * Renders the book list inside the app theme using 20 sample books for UI inspection in design tools. + */ +@Preview() +@Composable +fun PreviewBookListWithScrollbar() { + ThipTheme { + Column { + GroupBookListWithScrollbar( + books = List(20) { BookData("Book $it", R.drawable.bookcover_sample) }, + onBookClick = {} + ) + } + } +} + diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookSearchBottomSheet.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookSearchBottomSheet.kt new file mode 100644 index 00000000..829b7735 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookSearchBottomSheet.kt @@ -0,0 +1,157 @@ +package com.texthip.thip.ui.group.makeroom.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.texthip.thip.R +import com.texthip.thip.ui.common.forms.SearchBookTextField +import com.texthip.thip.ui.group.makeroom.mock.BookData +import com.texthip.thip.ui.group.makeroom.mock.dummyGroupBooks +import com.texthip.thip.ui.group.makeroom.mock.dummySavedBooks +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.common.bottomsheet.CustomBottomSheet +import com.texthip.thip.ui.common.header.HeaderMenuBarTab + +/** + * Displays a bottom sheet for searching and selecting books within a group context. + * + * Shows a search field, tabs for switching between saved and group books (if available), and a scrollable list of books or an empty state prompt. Allows users to select a book or request a new one if no books are found. + * + * @param onDismiss Callback invoked when the bottom sheet is dismissed. + * @param onBookSelect Callback invoked when a book is selected. + * @param onRequestBook Callback invoked when the user requests a book not found in the list. + * @param savedBooks List of saved books to display in the first tab. + * @param groupBooks List of group books to display in the second tab. + * @param defaultTab Index of the tab selected initially (0 for saved books, 1 for group books). + */ +@Composable +fun GroupBookSearchBottomSheet( + onDismiss: () -> Unit, + onBookSelect: (BookData) -> Unit, + onRequestBook: () -> Unit, + savedBooks: List = emptyList(), + groupBooks: List = emptyList(), + defaultTab: Int = 0 +) { + // 책이 있는지 여부 체크 + val hasBooks = savedBooks.isNotEmpty() || groupBooks.isNotEmpty() + var selectedTab by rememberSaveable { mutableIntStateOf(defaultTab) } + val tabs = listOf( + stringResource(R.string.group_saved_book), stringResource(R.string.group_book) + ) + val books = if (selectedTab == 0) savedBooks else groupBooks + + CustomBottomSheet( + onDismiss = onDismiss + ) { + Column( + Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 20.dp) + ) { + // 검색창 + SearchBookTextField( + hint = stringResource(R.string.group_book_search_hint), + onSearch = { /* 검색 구현 */ } + ) + Spacer(Modifier.height(20.dp)) + } + + if (hasBooks) { + HeaderMenuBarTab( + titles = tabs, + selectedTabIndex = selectedTab, + onTabSelected = { selectedTab = it }, + indicatorColor = ThipTheme.colors.White, + modifier = Modifier.fillMaxWidth() + ) + + Column( + Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, bottom = 20.dp) + ) { + Spacer(Modifier.height(20.dp)) + if (books.isEmpty()) { + EmptyBookSheetContent(onRequestBook = onRequestBook) + } else { + GroupBookListWithScrollbar( + books = books, + onBookClick = onBookSelect + ) + } + } + } else { + // 탭 없이 바로 안내화면 + Column( + Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, bottom = 20.dp) + ) { + Spacer(Modifier.height(20.dp)) + EmptyBookSheetContent(onRequestBook = onRequestBook) + } + } + } +} + + + +/** + * Displays a preview of the GroupBookSearchBottomSheet composable with sample saved and group books. + * + * This preview demonstrates the bottom sheet UI when both savedBooks and groupBooks contain data. + */ +@Preview(showBackground = true) +@Composable +fun PreviewBookSearchBottomSheet_HasBooks() { + ThipTheme { + var showSheet by remember { mutableStateOf(true) } + if (showSheet) { + GroupBookSearchBottomSheet( + onDismiss = { showSheet = false }, + onBookSelect = {}, + onRequestBook = {}, + savedBooks = dummySavedBooks, // 데이터 있음 + groupBooks = dummyGroupBooks, + defaultTab = 0 + ) + } + } +} + +/** + * Preview composable displaying the group book search bottom sheet with empty saved and group book lists. + * + * Shows the bottom sheet initially and allows it to be dismissed. + */ +@Preview(showBackground = true) +@Composable +fun PreviewBookSearchBottomSheet_Empty() { + ThipTheme { + var showSheet by remember { mutableStateOf(true) } + if (showSheet) { + GroupBookSearchBottomSheet( + onDismiss = { showSheet = false }, + onBookSelect = {}, + onRequestBook = {}, + savedBooks = emptyList(), // 데이터 없음 + groupBooks = emptyList(), + defaultTab = 0 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupDatePicker.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupDatePicker.kt new file mode 100644 index 00000000..466d92c8 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupDatePicker.kt @@ -0,0 +1,163 @@ +package com.texthip.thip.ui.group.makeroom.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +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.texthip.thip.R +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography +import java.time.LocalDate + +/** + * Displays a horizontal date picker with wheel selectors for year, month, and day. + * + * Users can select a year, month, and day from the provided lists. The currently selected values are highlighted, and callbacks are invoked when a new value is chosen for each component. + */ +@Composable +fun GroupDatePicker( + modifier: Modifier = Modifier, + year: Int, + month: Int, + day: Int, + years: List, + months: List, + days: List, + onYearSelected: (Int) -> Unit, + onMonthSelected: (Int) -> Unit, + onDaySelected: (Int) -> Unit +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + // 년도 선택기 + GroupWheelPicker( + modifier = Modifier.width(48.dp), + items = years, + selectedItem = year, + onItemSelected = onYearSelected, + displayText = { it.toString() } + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(R.string.group_year), + style = typography.info_r400_s12, + color = colors.White + ) + Spacer(modifier = Modifier.width(4.dp)) + + // 월 선택기 + GroupWheelPicker( + modifier = Modifier.width(32.dp), + items = months, + selectedItem = month, + onItemSelected = onMonthSelected, + displayText = { it.toString() } + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(R.string.group_month), + style = typography.info_r400_s12, + color = colors.White + ) + Spacer(modifier = Modifier.width(4.dp)) + + // 일 선택기 + GroupWheelPicker( + modifier = Modifier.width(32.dp), + items = days, + selectedItem = day, + onItemSelected = onDaySelected, + displayText = { it.toString() } + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(R.string.group_day), + style = typography.info_r400_s12, + color = colors.White + ) + } +} + +/** + * Displays a preview of the GroupDatePicker component with two independently managed date pickers for selecting start and end dates. + * + * Demonstrates usage of GroupDatePicker within a themed container, initializing start and end dates to today and tomorrow, and dynamically updating the valid days based on the selected year and month. + */ +@Preview(showBackground = true) +@Composable +fun DatePickerGroupPreview() { + ThipTheme { + val today = LocalDate.now() + val years = (2020..2030).toList() + val months = (1..12).toList() + val getDaysInMonth = { year: Int, month: Int -> + val date = LocalDate.of(year, month, 1) + (1..date.lengthOfMonth()).toList() + } + + // 각각 독립적으로 관리! + var startYear by remember { mutableStateOf(today.year) } + var startMonth by remember { mutableStateOf(today.monthValue) } + var startDay by remember { mutableStateOf(today.dayOfMonth) } + + val tomorrow = today.plusDays(1) + var endYear by remember { mutableStateOf(tomorrow.year) } + var endMonth by remember { mutableStateOf(tomorrow.monthValue) } + var endDay by remember { mutableStateOf(tomorrow.dayOfMonth) } + + Box( + modifier = Modifier + .background(colors.Black) + .padding(16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // 시작 날짜 + Text("시작 날짜", color = colors.White) + GroupDatePicker( + year = startYear, + month = startMonth, + day = startDay, + years = years, + months = months, + days = getDaysInMonth(startYear, startMonth), + onYearSelected = { startYear = it }, + onMonthSelected = { startMonth = it }, + onDaySelected = { startDay = it } + ) + // 끝 날짜 + Text("끝 날짜", color = colors.White) + GroupDatePicker( + year = endYear, + month = endMonth, + day = endDay, + years = years, + months = months, + days = getDaysInMonth(endYear, endMonth), + onYearSelected = { endYear = it }, + onMonthSelected = { endMonth = it }, + onDaySelected = { endDay = it } + ) + } + } + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupEmptyBookSheetContent.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupEmptyBookSheetContent.kt new file mode 100644 index 00000000..1ddc1b97 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupEmptyBookSheetContent.kt @@ -0,0 +1,68 @@ +package com.texthip.thip.ui.group.makeroom.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.texthip.thip.R +import com.texthip.thip.ui.common.buttons.ActionMediumButton +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors + +/** + * Displays a centered UI with an icon, descriptive text, and an action button for requesting a book. + * + * @param onRequestBook Callback invoked when the action button is clicked. + */ +@Composable +fun EmptyBookSheetContent( + onRequestBook: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 30.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_notice), + contentDescription = null + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.group_register_book_comment), + color = ThipTheme.colors.Grey02, + style = ThipTheme.typography.copy_m500_s14_h20 + ) + Spacer(Modifier.height(24.dp)) + + ActionMediumButton( + text = stringResource(R.string.group_register_book), + contentColor = colors.White, + backgroundColor = colors.Purple, + modifier = Modifier + .width(97.dp) + .height(44.dp), + onClick = { onRequestBook() }, + ) + } +} + +/** + * Displays a preview of the EmptyBookSheetContent composable within the app's theme for design-time visualization. + */ +@Preview +@Composable +private fun EmptyBookSheetContentPreview() { + ThipTheme { + EmptyBookSheetContent( + onRequestBook = {} + ) + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupInputField.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupInputField.kt new file mode 100644 index 00000000..27f4f8fc --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupInputField.kt @@ -0,0 +1,119 @@ +package com.texthip.thip.ui.group.makeroom.component + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography + +/** + * Displays a labeled text input field with a character limit and live character count. + * + * Shows a title above the input, a hint when the field is empty, and restricts input to a maximum length. + * The character count is displayed below the input and changes color when the limit is reached. + * + * @param title The label displayed above the input field. + * @param hint The placeholder text shown when the input is empty. + * @param value The current text value of the input field. + * @param onValueChange Callback invoked with the new value when the input changes and does not exceed the maximum length. + * @param maxLength The maximum number of characters allowed in the input. Defaults to 75. + */ +@Composable +fun GroupInputField( + title: String, + hint: String, + value: String, + onValueChange: (String) -> Unit, + maxLength: Int = 75 +) { + val isOverflow = value.length >= maxLength + + Column( + Modifier.fillMaxWidth() + ) { + Text( + text = title, + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + + Box(modifier = Modifier.fillMaxWidth()) { + BasicTextField( + value = value, + onValueChange = { new -> + if (new.length <= maxLength) onValueChange(new) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 12.dp), + textStyle = typography.menu_r400_s14_h24.copy(color = colors.White), + cursorBrush = SolidColor(colors.NeonGreen), + decorationBox = { innerTextField -> + Box( + Modifier.fillMaxWidth() + ) { + if (value.isEmpty()) { + Text( + hint, + style = typography.menu_r400_s14_h24, + color = colors.Grey02 + ) + } + innerTextField() + } + } + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Text( + text = "${value.length} / $maxLength", + style = typography.info_r400_s12, + color = if (isOverflow) colors.Red else colors.NeonGreen + ) + } + } +} + + +/** + * Displays a preview of the GroupInputField composable with a sample configuration for room title input. + * + * Shows the input field with a title, hint, and a 15-character limit inside the app's theme for design inspection. + */ +@Preview() +@Composable +fun PreviewRoomTitleInputField() { + var text by remember { mutableStateOf("") } + + ThipTheme { + Column( + modifier = Modifier.fillMaxWidth() + ) { + GroupInputField( + title = "방 제목", + hint = "방 제목을 입력해주세요", + value = text, + onValueChange = { text = it }, + maxLength = 15 + ) + } + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupMemberLimitPicker.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupMemberLimitPicker.kt new file mode 100644 index 00000000..02bf446f --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupMemberLimitPicker.kt @@ -0,0 +1,110 @@ +package com.texthip.thip.ui.group.makeroom.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.texthip.thip.R +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography + +/** + * Displays a UI component for selecting a group member limit between 1 and 30. + * + * Shows a title, a horizontal wheel picker for choosing the member count, a unit label, and an informational message. Invokes the provided callback when the selected count changes. + * + * @param selectedCount The currently selected member count. + * @param onCountSelected Callback invoked when a new member count is selected. + */ +@Composable +fun MemberLimitPicker( + modifier: Modifier = Modifier, + selectedCount: Int = 30, + onCountSelected: (Int) -> Unit = { } +) { + val memberCounts = remember { (1..30).toList() } + + Column( + modifier = modifier.fillMaxWidth() + ) { + // 제목 + Text( + text = stringResource(R.string.group_room_member_limit_title), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + + // 인원 선택기 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // 숫자 선택기 + GroupWheelPicker( + modifier = Modifier.width(32.dp), + items = memberCounts, + selectedItem = selectedCount, + onItemSelected = onCountSelected, + displayText = { it.toString() } + ) + + // 단위 텍스트 + Text( + text = stringResource(R.string.group_room_limit), + style = typography.info_r400_s12, + color = colors.White, + modifier = Modifier.padding(start = 8.dp) + ) + } + + // 안내 메시지 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Text( + text = stringResource(R.string.group_room_member_limit_comment), + style = typography.info_r400_s12, + color = colors.NeonGreen, + textAlign = TextAlign.End, + modifier = Modifier.padding(top = 12.dp) + ) + } + } +} + +/** + * Displays a preview of the MemberLimitPicker composable with interactive state management. + * + * This preview allows visual inspection and interaction with the MemberLimitPicker component in isolation, using the app's theme and a mutable state for the selected member count. + */ +@Preview(showBackground = true) +@Composable +fun MemberLimitPickerPreview() { + ThipTheme { + var selectedCount by remember { mutableStateOf(30) } + + MemberLimitPicker( + selectedCount = selectedCount, + onCountSelected = { selectedCount = it } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupRoomDurationPicker.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupRoomDurationPicker.kt new file mode 100644 index 00000000..2c96c0b2 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupRoomDurationPicker.kt @@ -0,0 +1,261 @@ +package com.texthip.thip.ui.group.makeroom.component + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.texthip.thip.R +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** + * Displays a date range picker for selecting a group room duration within a constrained window. + * + * Allows the user to select a start date from today up to three months ahead. The end date is automatically set to one day after the selected start date and is read-only. The component enforces valid date selections, prevents choosing dates before today, and ensures the duration does not exceed 90 days. Informational and error messages are shown based on the current selection and user interaction. + * + * @param modifier Modifier for styling and layout. + * @param onDateRangeSelected Callback invoked with the selected start and end dates whenever the selection changes. + */ +@Composable +fun GroupRoomDurationPicker( + modifier: Modifier = Modifier, + onDateRangeSelected: (LocalDate, LocalDate) -> Unit = { _, _ -> } +) { + val today = LocalDate.now() + val maxDate = today.plusMonths(3) + + // 날짜 상태 + var startYear by rememberSaveable { mutableStateOf(today.year) } + var startMonth by rememberSaveable { mutableStateOf(today.monthValue) } + var startDay by rememberSaveable { mutableStateOf(today.dayOfMonth) } + + // 유효한 날짜 범위 계산 + val years = remember { (today.year..maxDate.year).toList() } + + // 월 범위 계산 (년도에 따라 동적으로) + val months = remember(startYear) { + when (startYear) { + today.year -> (today.monthValue..12).toList() + maxDate.year -> (1..maxDate.monthValue).toList() + else -> (1..12).toList() + } + } + + // 일 범위 계산 (년도, 월에 따라 동적으로) + val days = remember(startYear, startMonth) { + val selectedDate = LocalDate.of(startYear, startMonth, 1) + val startDayOfMonth = if (startYear == today.year && startMonth == today.monthValue) { + today.dayOfMonth + } else { + 1 + } + + val endDayOfMonth = if (startYear == maxDate.year && startMonth == maxDate.monthValue) { + minOf(selectedDate.lengthOfMonth(), maxDate.dayOfMonth) + } else { + selectedDate.lengthOfMonth() + } + + (startDayOfMonth..endDayOfMonth).toList() + } + + // 날짜 유효성 검사 및 자동 보정 + LaunchedEffect(startYear, startMonth, days) { + if (startDay !in days) { + startDay = days.lastOrNull() ?: startDay + } + } + + // 오늘 이전 날짜 선택 방지 + LaunchedEffect(startYear, startMonth, startDay) { + val selectedDate = LocalDate.of(startYear, startMonth, startDay) + if (selectedDate.isBefore(today)) { + startYear = today.year + startMonth = today.monthValue + startDay = today.dayOfMonth + } + } + + // 날짜 객체로 변환 + val startDate = remember(startYear, startMonth, startDay) { + try { + LocalDate.of(startYear, startMonth, startDay) + } catch (e: Exception) { + // 유효하지 않은 날짜인 경우 오늘 날짜로 fallback + today + } + } + val endDate = remember(startDate) { startDate.plusDays(1) } + + // 90일 초과 체크 + val daysBetween = ChronoUnit.DAYS.between(startDate, endDate) + val isOverLimit = daysBetween > 90 + + var isPickerTouched by rememberSaveable { mutableStateOf(false) } + + // 날짜 변경 시 콜백 + LaunchedEffect(startDate, endDate) { + onDateRangeSelected(startDate, endDate) + } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.group_room_duration_title), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, start = 12.dp, end = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // 시작 날짜 + GroupDatePicker( + year = startYear, + month = startMonth, + day = startDay, + years = years, + months = months, + days = days, + onYearSelected = { newYear -> + startYear = newYear + // 년도 변경 시 월 유효성 검사 + val validMonths = when (newYear) { + today.year -> (today.monthValue..12).toList() + maxDate.year -> (1..maxDate.monthValue).toList() + else -> (1..12).toList() + } + if (startMonth !in validMonths) { + startMonth = validMonths.first() + } + }, + onMonthSelected = { newMonth -> + startMonth = newMonth + // 월 변경 시 일 유효성 검사 + val tempDate = LocalDate.of(startYear, newMonth, 1) + val validStartDay = if (startYear == today.year && newMonth == today.monthValue) { + today.dayOfMonth + } else { + 1 + } + + val validEndDay = if (startYear == maxDate.year && newMonth == maxDate.monthValue) { + minOf(tempDate.lengthOfMonth(), maxDate.dayOfMonth) + } else { + tempDate.lengthOfMonth() + } + + if (startDay < validStartDay) { + startDay = validStartDay + } else if (startDay > validEndDay) { + startDay = validEndDay + } + }, + onDaySelected = { startDay = it }, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onPress = { isPickerTouched = true } + ) + } + ) + // 구분자 + Text( + text = "~", + style = typography.info_r400_s12, + color = colors.White, + modifier = Modifier.padding(horizontal = 4.dp) + ) + // 종료 날짜(선택 불가, 읽기 전용) + GroupDatePicker( + year = endDate.year, + month = endDate.monthValue, + day = endDate.dayOfMonth, + years = years, // 전체 년도 범위 제공 + months = (1..12).toList(), // 전체 월 범위 제공 + days = (1..LocalDate.of(endDate.year, endDate.monthValue, 1).lengthOfMonth()).toList(), // 해당 월의 전체 일 범위 제공 + onYearSelected = {}, + onMonthSelected = {}, + onDaySelected = {}, + modifier = Modifier // 비활성화 UI 추가 가능 + ) + } + // 안내/에러 메시지 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + when { + isOverLimit -> { + Text( + text = stringResource(R.string.group_room_duration_comment), + style = typography.info_r400_s12, + color = colors.Red, + textAlign = TextAlign.End, + modifier = Modifier.padding(top = 12.dp) + ) + } + !isPickerTouched -> { + Text( + text = stringResource(R.string.group_room_duration_comment), + style = typography.info_r400_s12, + color = colors.NeonGreen, + textAlign = TextAlign.End, + modifier = Modifier.padding(top = 12.dp) + ) + } + else -> { + Text( + text = "${startDate.monthValue}월 ${startDate.dayOfMonth}일 자정에 자동으로 모집 마감되고 활동이 가능합니다.", + style = typography.info_r400_s12, + color = colors.NeonGreen, + textAlign = TextAlign.End, + modifier = Modifier.padding(top = 12.dp) + ) + } + } + } + // 에러 메시지: 90일 초과 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + if (isOverLimit) { + Text( + text = stringResource(R.string.group_room_duration_error, daysBetween), + style = typography.info_r400_s12, + color = colors.Red, + textAlign = TextAlign.End, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } +} + +/** + * Displays a preview of the GroupRoomDurationPicker component within the app theme. + * + * This preview allows interactive selection of a date range and prints the selected range to the console. + */ +@Preview(showBackground = true) +@Composable +fun MeetingDurationPickerPreview() { + ThipTheme { + GroupRoomDurationPicker { startDate, endDate -> + println("Selected date range: $startDate to $endDate") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupSelectBook.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupSelectBook.kt new file mode 100644 index 00000000..aeeb7d54 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupSelectBook.kt @@ -0,0 +1,177 @@ +package com.texthip.thip.ui.group.makeroom.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.texthip.thip.R +import com.texthip.thip.ui.common.buttons.OptionChipButton +import com.texthip.thip.ui.group.makeroom.mock.BookData +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography + +/** + * Displays a UI component for selecting or displaying a selected book in a group creation flow. + * + * Shows a title and either a search prompt (when no book is selected) or the selected book's details with an option to change the selection. + * + * @param selectedBook The currently selected book, or null if no book is selected. + * @param onChangeBookClick Callback invoked when the "Change" button is clicked. + * @param onSelectBookClick Callback invoked when the search area is clicked to select a book. + * @param modifier Modifier for styling and layout customization. + */ +@Composable +fun GroupSelectBook( + selectedBook: BookData?, + onChangeBookClick: () -> Unit, + onSelectBookClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.group_select_book_title), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White, + modifier = Modifier.align(Alignment.Start) + ) + Spacer(modifier = Modifier.height(20.dp)) + + if (selectedBook == null) { + // 미선택 상태: 기존 검색 UI + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clickable { onSelectBookClick() } + .fillMaxWidth() + .height(44.dp) + .border( + BorderStroke(1.dp, colors.Grey02), + shape = RoundedCornerShape(12.dp) + ) + .padding(vertical = 10.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = "검색 아이콘", + tint = colors.Grey01 + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "검색해서 찾기", + style = typography.menu_m500_s16_h24, + color = colors.Grey + ) + } + } else { + // 선택된 상태: 커버, 제목, 저자, 변경 버튼 + Row( + modifier = Modifier + .fillMaxWidth() + .height(80.dp), + verticalAlignment = Alignment.Bottom + ) { + Image( + painter = painterResource(selectedBook.imageRes), + contentDescription = selectedBook.title, + modifier = Modifier + .height(80.dp) + .width(60.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .weight(1f), + verticalArrangement = Arrangement.Top + ) { + Text( + text = selectedBook.title, + color = colors.White, + style = typography.menu_sb600_s14_h24 + ) + Spacer(modifier = Modifier.height(8.dp)) + selectedBook.author?.let { + Text( + text = stringResource( + R.string.group_selected_book_author, + selectedBook.author + ), + color = colors.Grey01, + style = typography.info_r400_s12, + maxLines = 1 + ) + } + } + OptionChipButton( + text = stringResource(R.string.change), + onClick = onChangeBookClick, + isSelected = true + ) + } + } + } +} + +// ------------------------- +// 프리뷰용 더미 BookData +// ------------------------- +private val dummyBook = BookData( + title = "호르몬 체인지", + imageRes = R.drawable.bookcover_sample, // drawable 샘플로 교체 + author = "최정화" +) + +// ------------------------- +// PREVIEW: 책 미선택 상태 +/** + * Preview of the GroupSelectBook composable in the unselected state, showing the UI when no book is selected. + */ +@Preview(showBackground = true) +@Composable +fun GroupSelectBookPreview_Unselected() { + ThipTheme { + GroupSelectBook( + selectedBook = null, + onChangeBookClick = {}, + onSelectBookClick = {} + ) + } +} + +// ------------------------- +// PREVIEW: 책 선택된 상태 +/** + * Preview of the GroupSelectBook composable with a selected book. + * + * Displays the UI as it appears when a book is selected, using dummy data for demonstration. + */ +@Preview(showBackground = true) +@Composable +fun GroupSelectBookPreview_Selected() { + ThipTheme { + GroupSelectBook( + selectedBook = dummyBook, + onChangeBookClick = {}, + onSelectBookClick = {} + ) + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupWheelPicker.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupWheelPicker.kt new file mode 100644 index 00000000..e8960de1 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupWheelPicker.kt @@ -0,0 +1,302 @@ +package com.texthip.thip.ui.group.makeroom.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Displays a vertically scrollable wheel picker for selecting an item from a list, with optional circular scrolling and animated snapping. + * + * The picker centers the selected item and allows users to scroll or drag to change the selection. Circular (infinite) scrolling is enabled if specified and the item count is greater than two. Scrolling is disabled if only one item is present. The currently selected item is highlighted, and a callback is invoked when the selection changes. + * + * @param modifier Modifier for styling and layout. + * @param items List of items to display in the picker. + * @param selectedItem The currently selected item. + * @param onItemSelected Callback invoked when the selected item changes. + * @param displayText Function to convert an item to its display string. + * @param selectedBackgroundColor Background color for the selected item highlight. + * @param itemHeight Height of each item in dp. + * @param isCircular Enables circular scrolling if true and item count is greater than two. + */ +@Composable +fun GroupWheelPicker( + modifier: Modifier = Modifier, + items: List, + selectedItem: T, + onItemSelected: (T) -> Unit, + displayText: (T) -> String = { it.toString() }, + selectedBackgroundColor: Color = colors.DarkGrey50, + itemHeight: Int = 20, + isCircular: Boolean = true +) { + if (items.isEmpty()) return + + // 아이템이 하나인 경우 스크롤 비활성화 + val isScrollEnabled = items.size > 1 + val circular = isCircular && items.size > 2 + + val selectedIndex = items.indexOf(selectedItem) + val animatableOffset = remember { Animatable(0f) } + val coroutineScope = rememberCoroutineScope() + var isDragging by remember { mutableStateOf(false) } + var velocity by remember { mutableFloatStateOf(0f) } + + val density = LocalDensity.current + val itemHeightPx = with(density) { itemHeight.dp.toPx() } + val spacingPx = with(density) { 9.dp.toPx() } + val itemSpacing = itemHeightPx + spacingPx + + // index가 음수/양수 모두에서 올바르게 작동하도록 + fun getCircularIndex(index: Int): Int { + val size = items.size + return ((index % size) + size) % size + } + + // offset을 항상 0 ~ (items.size-1)*itemSpacing 범위로 + fun normalizeOffset(offset: Float): Float { + if (!circular) return offset + val total = items.size * itemSpacing + return ((offset % total) + total) % total + } + + // 오프셋을 아이템 인덱스로 변환 (0이 중앙) + fun offsetToIndex(offset: Float): Int { + val total = items.size * itemSpacing + val normalized = if (circular) normalizeOffset(offset) else offset + val centerIndex = (-normalized / itemSpacing).roundToInt() + return if (circular) getCircularIndex(centerIndex) else centerIndex.coerceIn( + 0, + items.size - 1 + ) + } + + // 선택 아이템이 바뀌면 중앙에 오도록 offset 이동 + LaunchedEffect(selectedItem) { + if (!isDragging && isScrollEnabled) { + val targetOffset = -selectedIndex * itemSpacing + animatableOffset.animateTo( + if (circular) normalizeOffset(targetOffset) else targetOffset, + animationSpec = spring() + ) + } + } + + // 오프셋이 바뀔 때 마다 선택 아이템을 갱신 + LaunchedEffect(animatableOffset.value) { + if (!isDragging && isScrollEnabled) { + val newSelectedIndex = offsetToIndex(animatableOffset.value) + if (items[newSelectedIndex] != selectedItem) { + onItemSelected(items[newSelectedIndex]) + } + } + } + + Box( + modifier = modifier + .height((itemHeight * 3 + 36).dp) + ) { + // 중앙 고정 박스 + Box( + modifier = Modifier + .align(Alignment.Center) + .background( + selectedBackgroundColor, + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 9.dp) + ) { + Text( + text = displayText(selectedItem), + style = typography.info_r400_s12, + color = Color.Transparent, + textAlign = TextAlign.Center + ) + } + + // 아이템들 + Box( + modifier = Modifier + .fillMaxSize() + .then( + if (isScrollEnabled) { + Modifier.pointerInput(Unit) { + detectDragGestures( + onDragStart = { + isDragging = true + velocity = 0f + }, + onDragEnd = { + isDragging = false + coroutineScope.launch { + // 관성 스크롤 + if (abs(velocity) > 100f) { + animatableOffset.animateDecay( + initialVelocity = velocity, + animationSpec = exponentialDecay( + frictionMultiplier = 0.9f, + absVelocityThreshold = 0.1f + ) + ) + } + + // offset 스냅 + val currOffset = animatableOffset.value + val normalized = + if (circular) normalizeOffset(currOffset) else currOffset + val snapIndex = (-normalized / itemSpacing).roundToInt() + val snapOffset = -snapIndex * itemSpacing + animatableOffset.animateTo( + if (circular) normalizeOffset(snapOffset) else snapOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 400f + ) + ) + } + } + ) { _, dragAmount -> + velocity = dragAmount.y + coroutineScope.launch { + val newOffset = animatableOffset.value + dragAmount.y + if (circular) { + animatableOffset.snapTo(normalizeOffset(newOffset)) + } else { + val maxOffset = itemSpacing + spacingPx + val minOffset = + -(items.size - 1) * itemSpacing - itemSpacing - spacingPx + animatableOffset.snapTo( + newOffset.coerceIn( + minOffset, + maxOffset + ) + ) + } + } + } + } + } else { + Modifier + } + ) + ) { + val currOffset = + if (circular && isScrollEnabled) normalizeOffset(animatableOffset.value) else animatableOffset.value + val centerIndex = if (isScrollEnabled) (-currOffset / itemSpacing).roundToInt() else 0 + + // 중앙 + 위 아래 한 개만 보이도록! + val visibleRange = if (isScrollEnabled) -1..1 else 0..0 + + visibleRange.forEach { relIdx -> + val displayIndex = centerIndex + relIdx + val actualIndex = + if (circular && isScrollEnabled) getCircularIndex(displayIndex) else { + if (displayIndex in 0 until items.size) displayIndex else return@forEach + } + val item = items[actualIndex] + val itemOffset = + if (isScrollEnabled) currOffset + (displayIndex * itemSpacing) else 0f + val itemY = itemOffset + itemSpacing + spacingPx + + Box( + modifier = Modifier + .fillMaxWidth() + .height(itemHeight.dp) + .offset { IntOffset(0, itemY.roundToInt()) }, + contentAlignment = Alignment.Center + ) { + Text( + text = displayText(item), + style = typography.info_r400_s12, + color = colors.White, + textAlign = TextAlign.Center + ) + } + } + } + + // 그라데이션 오버레이 (아이템이 여러 개일 때만 표시) + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + colors.Black.copy(alpha = 0.8f), + Color.Transparent, + Color.Transparent, + colors.Black.copy(alpha = 0.8f) + ), + startY = 0f, + endY = Float.POSITIVE_INFINITY + ) + ) + ) + } +} + + +/** + * Displays a preview of the GroupWheelPicker composable with two examples: a circular year picker and a single-item picker. + * + * Demonstrates usage of the wheel picker with both multiple and single item lists, showing circular and non-circular scrolling behavior. + */ +@Preview(showBackground = true) +@Composable +fun WheelPickerPreview() { + var selectedYear by remember { mutableStateOf(2025) } + val years = (2020..2030).toList() // 11개 + + var selectedSingleItem by remember { mutableStateOf("Only Item") } + val singleItemList = listOf("Only Item") // 1개 + + Box( + modifier = Modifier + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // 순환식 년 선택기 (4개 이상이므로 순환) + GroupWheelPicker( + modifier = Modifier.width(60.dp), + items = years, + selectedItem = selectedYear, + onItemSelected = { selectedYear = it }, + displayText = { it.toString() }, + isCircular = true + ) + + // 단일 아이템 선택기 (스크롤 비활성화) + GroupWheelPicker( + modifier = Modifier.width(60.dp), + items = singleItemList, + selectedItem = selectedSingleItem, + onItemSelected = { selectedSingleItem = it }, + displayText = { it }, + isCircular = false + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt new file mode 100644 index 00000000..cf48103e --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt @@ -0,0 +1,276 @@ +package com.texthip.thip.ui.group.makeroom.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.texthip.thip.R +import com.texthip.thip.ui.common.buttons.GenreChipRow +import com.texthip.thip.ui.common.buttons.ToggleSwitchButton +import com.texthip.thip.ui.common.forms.WarningTextField +import com.texthip.thip.ui.common.topappbar.InputTopAppBar +import com.texthip.thip.ui.group.makeroom.component.GroupSelectBook +import com.texthip.thip.ui.group.makeroom.component.GroupBookSearchBottomSheet +import com.texthip.thip.ui.group.makeroom.component.GroupInputField +import com.texthip.thip.ui.group.makeroom.component.GroupRoomDurationPicker +import com.texthip.thip.ui.group.makeroom.component.MemberLimitPicker +import com.texthip.thip.ui.group.makeroom.mock.BookData +import com.texthip.thip.ui.group.makeroom.mock.dummySavedBooks +import com.texthip.thip.ui.group.makeroom.mock.dummyGroupBooks +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** + * Displays the screen for creating a new group room, allowing users to select a book, genre, set room details, meeting dates, member limit, privacy settings, and password. + * + * The screen validates all required fields and enables the completion button only when inputs are valid. A bottom sheet is shown for book selection, and the UI adapts based on privacy settings. + */ +@Composable +fun GroupMakeRoomScreen(modifier: Modifier = Modifier) { + val scrollState = rememberScrollState() + + var selectedBook by remember { mutableStateOf(null) } + var showBookSearchSheet by remember { mutableStateOf(false) } + val genres = listOf("문학", "과학·IT", "사회과학", "인문학", "예술") + var selectedGenreIndex by remember { mutableIntStateOf(-1) } + var roomTitle by remember { mutableStateOf("") } + var roomDescription by remember { mutableStateOf("") } + var meetingStartDate by remember { mutableStateOf(LocalDate.now()) } + var meetingEndDate by remember { mutableStateOf(LocalDate.now().plusDays(1)) } + var selectedCount by remember { mutableStateOf(30) } + var isPrivate by remember { mutableStateOf(false) } + var password by remember { mutableStateOf("") } + + val daysBetween = ChronoUnit.DAYS.between(meetingStartDate, meetingEndDate) + val isDurationValid = (daysBetween in 1..90) + val isCountValid = selectedCount in 2..30 + val isPasswordValid = !isPrivate || (password.length == 4) + + val isButtonEnabled = selectedBook != null && + selectedGenreIndex >= 0 && + roomTitle.isNotBlank() && + roomDescription.isNotBlank() && + isDurationValid && + isCountValid && + isPasswordValid + + Box { + Column( + modifier = modifier + .fillMaxSize() + .then(if (showBookSearchSheet) Modifier.blur(5.dp) else Modifier), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + InputTopAppBar( + title = stringResource(R.string.group_making_group), + isRightButtonEnabled = isButtonEnabled, + onLeftClick = {}, + onRightClick = { + // 완료 버튼 클릭 로직 + } + ) + + Column( + modifier = Modifier + .verticalScroll(scrollState) + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.Top, + ) { + Spacer(modifier = Modifier.padding(top = 20.dp)) + + GroupSelectBook( + selectedBook = selectedBook, + onChangeBookClick = { showBookSearchSheet = true }, + onSelectBookClick = { showBookSearchSheet = true } + ) + + Spacer(modifier = Modifier.padding(top = 32.dp)) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + Spacer(modifier = Modifier.padding(top = 32.dp)) + + Text( + text = stringResource(R.string.group_book_genre), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White, + ) + Spacer(modifier = Modifier.padding(top = 12.dp)) + GenreChipRow( + modifier = Modifier.width(18.dp), + genres = genres, + selectedIndex = selectedGenreIndex, + onSelect = { selectedGenreIndex = it } + ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Text( + text = if (selectedGenreIndex >= 0) stringResource(R.string.group_genre_selected_comment) + else stringResource(R.string.group_genre_select_comment), + style = typography.info_r400_s12, + color = colors.NeonGreen + ) + } + + Spacer(modifier = Modifier.padding(top = 32.dp)) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + Spacer(modifier = Modifier.padding(top = 32.dp)) + + GroupInputField( + title = stringResource(R.string.group_room_title), + hint = stringResource(R.string.group_room_title_hint), + value = roomTitle, + maxLength = 15, + onValueChange = { roomTitle = it } + ) + + Spacer(modifier = Modifier.padding(top = 32.dp)) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + Spacer(modifier = Modifier.padding(top = 32.dp)) + + GroupInputField( + title = stringResource(R.string.group_room_explain), + hint = stringResource(R.string.group_room_explain_hint), + value = roomDescription, + onValueChange = { roomDescription = it } + ) + + Spacer(modifier = Modifier.padding(top = 32.dp)) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + Spacer(modifier = Modifier.padding(top = 32.dp)) + + GroupRoomDurationPicker( + onDateRangeSelected = { startDate, endDate -> + meetingStartDate = startDate + meetingEndDate = endDate + } + ) + + Spacer(modifier = Modifier.padding(bottom = 32.dp)) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + Spacer(modifier = Modifier.padding(top = 32.dp)) + + MemberLimitPicker( + selectedCount = selectedCount, + onCountSelected = { selectedCount = it } + ) + + Spacer(modifier = Modifier.padding(bottom = 32.dp)) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + Spacer(modifier = Modifier.padding(top = 32.dp)) + + // --- 공개 설정 --- + Text( + text = "공개 설정", + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + Spacer(modifier = Modifier.padding(top = 12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "비공개로 설정하기", + style = typography.menu_r400_s14_h24, + color = colors.White + ) + ToggleSwitchButton( + isChecked = isPrivate, + onToggleChange = { + isPrivate = it + if (!it) password = "" + } + ) + } + + if (isPrivate) { + Spacer(modifier = Modifier.height(12.dp)) + WarningTextField( + value = password, + onValueChange = { password = it }, + hint = stringResource(R.string.group_password_hint), + showWarning = password.isNotEmpty() && password.length < 4, + warningMessage = "4자리 숫자를 입력해주세요.", + maxLength = 4, + isNumberOnly = true, + keyboardType = KeyboardType.NumberPassword + ) + } + + Spacer(modifier = Modifier.padding(top = 134.dp)) + } + } + + if (showBookSearchSheet) { + GroupBookSearchBottomSheet( + onDismiss = { showBookSearchSheet = false }, + onBookSelect = { book: BookData -> + selectedBook = book + showBookSearchSheet = false + }, + onRequestBook = { + showBookSearchSheet = false + }, + savedBooks = dummySavedBooks, + groupBooks = dummyGroupBooks, + defaultTab = 0 + ) + } + } +} + +@Preview +@Composable +private fun GroupMakeRoomScreenPreview() { + ThipTheme { + GroupMakeRoomScreen() + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupRegisterBookScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupRegisterBookScreen.kt new file mode 100644 index 00000000..e199b041 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupRegisterBookScreen.kt @@ -0,0 +1,72 @@ +package com.texthip.thip.ui.group.makeroom.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.texthip.thip.R +import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography + +/** + * Displays the group book registration screen with a top app bar and informational text. + * + * The screen includes a localized title bar and two centered text elements providing instructions or information + * related to group book registration, styled according to the app theme. + * + * @param modifier Modifier to be applied to the root layout of the screen. + */ +@Composable +fun GroupRegisterBookScreen(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + DefaultTopAppBar( + title = stringResource(R.string.group_request_book), + onLeftClick = {}, + ) + Column ( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.group_thip_email), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + Spacer(modifier = Modifier.padding(top = 8.dp)) + + Text( + text = stringResource(R.string.group_request_book_comment), + style = typography.copy_r400_s14, + color = colors.White + ) + } + } +} + +/** + * Displays a preview of the GroupRegisterBookScreen composable with the app theme applied. + */ +@Preview +@Composable +private fun GroupRegisterBookPreview() { + ThipTheme { + GroupRegisterBookScreen() + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupDeadlineRoomSection.kt b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupDeadlineRoomSection.kt index c7e3f7a8..9762177c 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupDeadlineRoomSection.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupDeadlineRoomSection.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.texthip.thip.R +import com.texthip.thip.ui.common.buttons.GenreChipRow import com.texthip.thip.ui.common.cards.CardItemRoom import com.texthip.thip.ui.group.myroom.mock.GroupRoomSectionData import com.texthip.thip.ui.group.myroom.mock.GroupCardItemRoomData @@ -23,14 +24,21 @@ import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +/** + * Displays a horizontally scrollable pager of grouped room sections, each with genre filtering and up to three room cards. + * + * Each page represents a room section with a title, selectable genre chips, and a filtered list of room cards. Cards are visually scaled based on selection, and the pager layout dynamically adjusts to the container width. Invokes a callback when a room card is clicked. + * + * @param roomSections The list of room sections to display, each containing genres and associated rooms. + * @param onRoomClick Callback invoked when a room card is selected. + */ @SuppressLint("UnusedBoxWithConstraintsScope") @Composable fun GroupRoomDeadlineSection( roomSections: List, onRoomClick: (GroupCardItemRoomData) -> Unit ) { - val cardWidth = 320.dp - val pageSpacing = 12.dp + val sideMargin = 30.dp val pagerState = rememberPagerState( initialPage = 0, @@ -48,16 +56,21 @@ fun GroupRoomDeadlineSection( .height(588.dp), contentAlignment = Alignment.Center ) { - val horizontalPadding = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp) + val horizontalPadding = sideMargin + val cardWidth = maxWidth - (horizontalPadding * 2) + val scale = 0.9f + val desiredGap = 12.dp // TODO: 이 부분을 10dp로 하면 양 옆의 카드에 살짝 다음 내용이 보여서 12정도가 어떤지 + + val pageSpacing = (-(cardWidth - (cardWidth * scale)) / 2) + desiredGap HorizontalPager( state = pagerState, - contentPadding = PaddingValues(horizontal = horizontalPadding), + contentPadding = PaddingValues(horizontal = 30.dp), pageSpacing = pageSpacing, modifier = Modifier.fillMaxWidth() ) { page -> val section = roomSections[page] - var selectedGenre by remember { mutableStateOf(0) } + var selectedGenre by remember { mutableIntStateOf(0) } val isCurrent = pagerState.currentPage == page val scale = if (isCurrent) 1f else 0.9f @@ -79,7 +92,7 @@ fun GroupRoomDeadlineSection( ), shape = RoundedCornerShape(14.dp) ) - .padding(vertical = 20.dp, horizontal = 12.dp) + .padding(vertical = 20.dp, horizontal = 20.dp) ) { Column( horizontalAlignment = Alignment.CenterHorizontally @@ -127,17 +140,14 @@ fun GroupRoomDeadlineSection( } } } - - SimplePagerIndicator( - pageCount = roomSections.size, - currentPage = pagerState.currentPage, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(top = 8.dp) - ) } } +/** + * Displays a preview of the GroupRoomDeadlineSection composable with sample room section data and genres. + * + * This preview includes three types of room sections: rooms with imminent deadlines, popular rooms, and influencer/author rooms, each populated with example data. + */ @Preview() @Composable fun PreviewGroupRoomPagerSection() { @@ -259,4 +269,4 @@ fun PreviewGroupRoomPagerSection() { onRoomClick = {} ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupMainCard.kt b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupMainCard.kt index 98ac640b..3a04f975 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupMainCard.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupMainCard.kt @@ -39,6 +39,15 @@ import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +/** + * Displays a card representing a group reading room with book cover, title, participant count, user nickname, and progress. + * + * The card features a linear gradient background, rounded corners, and is fully clickable. It visually presents group details and a progress bar indicating the user's reading completion percentage. + * + * @param data The group card data containing title, image resource, participant count, nickname, and progress. + * @param backgroundColor The background color of the card. Defaults to white. + * @param onClick Callback invoked when the card is clicked. + */ @Composable fun GroupMainCard( data: GroupCardData, @@ -57,7 +66,7 @@ fun GroupMainCard( Card( modifier = Modifier - .width(320.dp) + .fillMaxWidth() .height(176.dp) .clickable { onClick() }, shape = RoundedCornerShape(18.dp), diff --git a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupPager.kt b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupPager.kt index 69147a66..82aa9b8d 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupPager.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GroupPager.kt @@ -15,22 +15,34 @@ import com.texthip.thip.ui.group.myroom.mock.GroupCardData import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors +/** + * Displays a horizontally scrollable pager of group cards with dynamic sizing and spacing. + * + * Each card scales and changes opacity based on its selection state, and a pager indicator shows the current page. + * + * @param groupCards The list of group card data to display in the pager. + * @param onCardClick Callback invoked when a group card is clicked. + */ @SuppressLint("UnusedBoxWithConstraintsScope") @Composable fun GroupPager( groupCards: List, onCardClick: (GroupCardData) -> Unit ) { - val cardWidth = 320.dp - val pageSpacing = 6.dp + val scale = 0.86f + val desiredGap = 10.dp BoxWithConstraints( modifier = Modifier .fillMaxWidth() .height(192.dp) ) { - val screenWidth = maxWidth - val horizontalPadding = ((screenWidth - cardWidth) / 2).coerceAtLeast(0.dp) + val horizontalPadding = 30.dp + val cardWidth = maxWidth - (horizontalPadding * 2) + + val pageSpacing = with(this) { + (-(cardWidth - (cardWidth * scale)) / 2f) + desiredGap + } val pagerState = rememberPagerState( initialPage = 0, diff --git a/app/src/main/java/com/texthip/thip/ui/group/myroom/screen/GroupMyScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/myroom/screen/GroupMyScreen.kt index 1938fbe2..5a8d803e 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/myroom/screen/GroupMyScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/myroom/screen/GroupMyScreen.kt @@ -27,6 +27,14 @@ import com.texthip.thip.ui.group.myroom.mock.GroupCardItemRoomData import com.texthip.thip.ui.group.myroom.component.GroupMyRoomFilterRow import com.texthip.thip.ui.theme.ThipTheme +/** + * Displays a screen listing group rooms with filter options for recruiting status. + * + * Shows a top app bar, filter toggles, and a scrollable list of group rooms. Users can filter the list to show only recruiting, only non-recruiting, or all rooms. Clicking a room card triggers the provided callback. + * + * @param allDataList The complete list of group room data to display and filter. + * @param onCardClick Callback invoked when a room card is clicked, receiving the selected room data. + */ @Composable fun GroupMyScreen( allDataList: List, @@ -70,11 +78,11 @@ fun GroupMyScreen( } ) - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(10.dp)) LazyColumn( verticalArrangement = Arrangement.spacedBy(20.dp), - contentPadding = PaddingValues(bottom = 20.dp), + contentPadding = PaddingValues(top = 10.dp, bottom = 20.dp), modifier = Modifier .fillMaxSize() ) { diff --git a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt index cfd918e8..8d44681b 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/group/screen/GroupScreen.kt @@ -33,6 +33,11 @@ import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors +/** + * Displays the main group screen with sections for the user's groups and groups nearing their deadlines. + * + * Presents a vertically scrollable layout including a top app bar, search field, "My Groups" section, group cards, and a deadline section. User interactions are handled via the provided ViewModel. + */ @Composable fun GroupScreen( navController: NavHostController? = null, @@ -55,7 +60,7 @@ fun GroupScreen( // 상단바 LogoTopAppBar( leftIcon = painterResource(R.drawable.ic_done), - hasNotification = false, + hasNotification = true, onLeftClick = { }, onRightClick = { } )