diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 00000000..91f95584 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 1e536c6c..62464c5c 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -2,5 +2,6 @@ + \ No newline at end of file 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..0c745866 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/common/bottomsheet/CustomBottomSheet.kt @@ -0,0 +1,141 @@ +package com.texthip.thip.ui.common.bottomsheet + + +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 + +@Composable +fun CustomBottomSheet( + onDismiss: () -> Unit, + 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() + } + } + } +} + + +@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/group/myroom/component/GenreChipRow.kt b/app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipRow.kt similarity index 68% rename from app/src/main/java/com/texthip/thip/ui/group/myroom/component/GenreChipRow.kt rename to app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipRow.kt index ae3fa69a..1a69c9f5 100644 --- a/app/src/main/java/com/texthip/thip/ui/group/myroom/component/GenreChipRow.kt +++ b/app/src/main/java/com/texthip/thip/ui/common/buttons/GenreChipRow.kt @@ -1,22 +1,21 @@ -package com.texthip.thip.ui.group.myroom.component +package com.texthip.thip.ui.common.buttons -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.texthip.thip.ui.common.buttons.OptionChipButton +import androidx.compose.ui.unit.dp @Composable fun GenreChipRow( + modifier: Modifier = Modifier.width(4.dp), genres: List, selectedIndex: Int, onSelect: (Int) -> Unit ) { Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center ) { genres.forEachIndexed { idx, genre -> OptionChipButton( @@ -25,6 +24,9 @@ fun GenreChipRow( isSelected = selectedIndex == idx, onClick = { onSelect(idx) } ) + if (idx < genres.size - 1) { + Spacer(modifier = modifier) + } } } } @@ -37,4 +39,4 @@ fun PreviewGenreChipRow() { selectedIndex = 0, onSelect = {} ) -} \ No newline at end of file +} 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..e9cdb408 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 @@ -28,7 +28,6 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun CardBookSearch( modifier: Modifier = Modifier, - number: Int, title: String, imageRes: Int? = R.drawable.bookcover_sample, // 기본 이미지 리소스 onClick: () -> Unit = {} @@ -36,18 +35,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 @@ -84,7 +74,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..f841086e --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/common/forms/SearchBookTextField.kt @@ -0,0 +1,105 @@ +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 + +@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 + ) + } +} + +@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..2074ddac 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 @@ -29,76 +31,92 @@ import com.texthip.thip.ui.theme.ThipTheme.typography @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) + ) } } + @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 ) } } @@ -106,13 +124,21 @@ fun WarningTextFieldPreviewEmpty() { @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..9d8965e6 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookListWithScrollbar.kt @@ -0,0 +1,74 @@ +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.layout.padding +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 + + +@Composable +fun GroupBookListWithScrollbar( + books: List, + onBookClick: (BookData) -> Unit +) { + val scrollState = rememberScrollState() + + Box( + Modifier + .fillMaxWidth() + .height(320.dp) + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .drawVerticalScrollbar(scrollState) + ) { + books.forEach { book -> + CardBookSearch( + title = book.title, + imageRes = book.imageRes, + onClick = { onBookClick(book) } + ) + + Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier + .fillMaxWidth() + .padding(end = 6.dp) + .height(1.dp) + .background(color = colors.Grey02) + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } +} + +@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..3c31caf8 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupBookSearchBottomSheet.kt @@ -0,0 +1,131 @@ +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 + +@Composable +fun GroupBookSearchBottomSheet( + onDismiss: () -> Unit, + onBookSelect: (BookData) -> Unit, + onRequestBook: () -> Unit, + savedBooks: List = emptyList(), + groupBooks: List = emptyList() +) { + // 책이 있는지 여부 체크 + val hasBooks = savedBooks.isNotEmpty() || groupBooks.isNotEmpty() + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + 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) + } + } + } +} + + + +@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 + ) + } + } +} + +@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() + ) + } + } +} \ 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..9b5655e0 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupDatePicker.kt @@ -0,0 +1,168 @@ +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.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.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 + +@Composable +fun GroupDatePicker( + selectedDate: LocalDate, + minDate: LocalDate, + maxDate: LocalDate, + onDateSelected: (LocalDate) -> Unit, + modifier: Modifier = Modifier +) { + // 선택된 날짜에서 년/월/일 추출 + val year = selectedDate.year + val month = selectedDate.monthValue + val day = selectedDate.dayOfMonth + + // 유효한 범위 계산 + val years = (minDate.year..maxDate.year).toList() + val months = (1..12).toList() + val days = (1..selectedDate.lengthOfMonth()).toList() + + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + GroupWheelPicker( + modifier = Modifier.width(48.dp), + items = years, + selectedItem = year, + onItemSelected = { newYear -> + val newDate = try { + LocalDate.of(newYear, month, day) + } catch (e: Exception) { + LocalDate.of(newYear, month, 1) + } + onDateSelected(newDate) + }, + displayText = { it.toString() } + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(R.string.group_year), + style = typography.info_r400_s12, + color = colors.White + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + GroupWheelPicker( + modifier = Modifier.width(32.dp), + items = months, + selectedItem = month, + onItemSelected = { newMonth -> + val newDate = try { + LocalDate.of(year, newMonth, day) + } catch (e: Exception) { + LocalDate.of(year, newMonth, 1) + } + onDateSelected(newDate) + }, + displayText = { it.toString() } + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(R.string.group_month), + style = typography.info_r400_s12, + color = colors.White + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + GroupWheelPicker( + modifier = Modifier.width(32.dp), + items = days, + selectedItem = day, + onItemSelected = { newDay -> + val newDate = LocalDate.of(year, month, newDay) + onDateSelected(newDate) + }, + displayText = { it.toString() } + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(R.string.group_day), + style = typography.info_r400_s12, + color = colors.White + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DatePickerGroupPreview() { + ThipTheme { + val today = LocalDate.now() + val tomorrow = today.plusDays(1) + val maxDate = today.plusMonths(12) + + var startDate by remember { mutableStateOf(today) } + var endDate by remember { mutableStateOf(tomorrow) } + + Box( + modifier = Modifier + .background(colors.Black) + .padding(16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // 시작 날짜 + Text("시작 날짜", color = colors.White) + GroupDatePicker( + selectedDate = startDate, + minDate = today, + maxDate = maxDate, + onDateSelected = { newDate -> + startDate = newDate + } + ) + + // 끝 날짜 + Text("끝 날짜", color = colors.White) + GroupDatePicker( + selectedDate = endDate, + minDate = today, + maxDate = maxDate, + onDateSelected = { newDate -> + endDate = newDate + } + ) + } + } + } +} \ No newline at end of file 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..d0d1028b --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupEmptyBookSheetContent.kt @@ -0,0 +1,66 @@ +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.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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 + +@Composable +fun EmptyBookSheetContent( + onRequestBook: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 30.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.ic_notification), + contentDescription = null, + tint = colors.Grey02 + ) + 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() }, + ) + } +} + +@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..2e4a589e --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupInputField.kt @@ -0,0 +1,104 @@ +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.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 + +@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 = stringResource(R.string.group_input_count, value.length, maxLength), + style = typography.info_r400_s12, + color = if (isOverflow) colors.Red else colors.NeonGreen + ) + } + } +} + + +@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..09d9d1cc --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupMemberLimitPicker.kt @@ -0,0 +1,84 @@ +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.mutableIntStateOf +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 + +@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) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MemberLimitPickerPreview() { + ThipTheme { + var selectedCount by remember { mutableIntStateOf(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..3b8acac4 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupRoomDurationPicker.kt @@ -0,0 +1,194 @@ +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 + +@Composable +fun GroupRoomDurationPicker( + modifier: Modifier = Modifier, + onDateRangeSelected: (LocalDate, LocalDate) -> Unit = { _, _ -> } +) { + val today = LocalDate.now() + val maxDate = today.plusMonths(12) + var isInitialized by rememberSaveable { mutableStateOf(false) } + + var startDate by rememberSaveable { mutableStateOf(today) } + var endDate by rememberSaveable { mutableStateOf(today.plusDays(1)) } + var isPickerTouched by rememberSaveable { mutableStateOf(false) } + + // 첫 시작 시에만 모든 날짜를 오늘 기준으로 초기화 + LaunchedEffect(Unit) { + if (!isInitialized) { + startDate = today + endDate = today.plusDays(1) + isInitialized = true + } + } + + // 날짜 범위 계산 + val daysBetween = ChronoUnit.DAYS.between(startDate, endDate) + val isOverLimit = daysBetween > 91 + + // 날짜 선택 콜백 + LaunchedEffect(startDate, endDate) { + if (endDate.isAfter(startDate)) { + onDateRangeSelected(startDate, endDate) + } + } + + // 날짜 유효성 검사 및 자동 조정 + LaunchedEffect(startDate) { + val adjustedStartDate = when { + startDate.isBefore(today) -> today + startDate.isAfter(maxDate) -> maxDate + else -> startDate + } + + if (adjustedStartDate != startDate) { + startDate = adjustedStartDate + } + + // 끝 날짜가 시작 날짜보다 빠르면 조정 + if (endDate.isBefore(startDate.plusDays(1))) { + endDate = startDate.plusDays(1) + } + } + + LaunchedEffect(endDate) { + val adjustedEndDate = when { + endDate.isAfter(maxDate) -> maxDate + endDate.isBefore(startDate.plusDays(1)) -> startDate.plusDays(1) + else -> endDate + } + + if (adjustedEndDate != endDate) { + endDate = adjustedEndDate + } + } + + 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), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // 시작 날짜 Picker + GroupDatePicker( + selectedDate = startDate, + minDate = today, + maxDate = maxDate, + onDateSelected = { newDate -> + startDate = newDate + }, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onPress = { isPickerTouched = true } + ) + } + ) + + // 구분자 + Text( + text = "~", + style = typography.info_r400_s12, + color = colors.White, + modifier = Modifier.padding(horizontal = 10.dp) + ) + + // 끝 날짜 Picker + GroupDatePicker( + selectedDate = endDate, + minDate = today, + maxDate = maxDate, + onDateSelected = { newDate -> + endDate = newDate + }, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onPress = { isPickerTouched = true } + ) + } + ) + } + + // 안내/에러 메시지 + 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_initial_comment), + style = typography.info_r400_s12, + color = colors.NeonGreen, + textAlign = TextAlign.End, + modifier = Modifier.padding(top = 12.dp) + ) + } + else -> { + Text( + text = stringResource( + R.string.group_room_duration_active_comment, + startDate.monthValue, + startDate.dayOfMonth + ), + style = typography.info_r400_s12, + color = colors.NeonGreen, + textAlign = TextAlign.End, + modifier = Modifier.padding(top = 12.dp) + ) + } + } + } + } +} + +@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..0b31c50b --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupSelectBook.kt @@ -0,0 +1,152 @@ +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 + +@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) { + 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 = stringResource(R.string.group_book_search), + 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 + ) + } + } + } +} + + +private val dummyBook = BookData( + title = "호르몬 체인지", + imageRes = R.drawable.bookcover_sample, + author = "최정화" +) + +@Preview(showBackground = true) +@Composable +fun GroupSelectBookPreview_Unselected() { + ThipTheme { + GroupSelectBook( + selectedBook = null, + onChangeBookClick = {}, + onSelectBookClick = {} + ) + } +} + +@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..b7d353c0 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/GroupWheelPicker.kt @@ -0,0 +1,271 @@ +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.group.makeroom.util.WheelPickerUtils +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 + +@Composable +fun GroupWheelPicker( + modifier: Modifier = Modifier, + items: List, + selectedItem: T, + onItemSelected: (T) -> Unit, + displayText: (T) -> String = { it.toString() }, + selectedBackgroundColor: Color = colors.DarkGrey, + 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 + + fun getCircularIndex(index: Int): Int { + return WheelPickerUtils.getCircularIndex(index, items.size) + } + + fun normalizeOffset(offset: Float): Float { + return WheelPickerUtils.normalizeOffset(offset, itemSpacing, items.size, circular) + } + + fun offsetToIndex(offset: Float): Int { + return WheelPickerUtils.offsetToIndex(offset, itemSpacing, items.size, circular) + } + + // 선택 아이템이 바뀌면 중앙에 오도록 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 + ) + ) + ) + } +} + + +@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/component/SectionDivider.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/SectionDivider.kt new file mode 100644 index 00000000..8643e413 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/component/SectionDivider.kt @@ -0,0 +1,23 @@ +package com.texthip.thip.ui.group.makeroom.component + +import androidx.compose.foundation.background +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.ui.Modifier +import androidx.compose.ui.unit.dp +import com.texthip.thip.ui.theme.ThipTheme.colors + +@Composable +fun SectionDivider() { + Spacer(modifier = Modifier.padding(top = 32.dp)) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + Spacer(modifier = Modifier.padding(top = 32.dp)) +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/mock/GroupBookData.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/mock/GroupBookData.kt new file mode 100644 index 00000000..7d3366ff --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/mock/GroupBookData.kt @@ -0,0 +1,30 @@ +package com.texthip.thip.ui.group.makeroom.mock + +import com.texthip.thip.R + +data class BookData( + val title: String, + val imageRes: Int, // drawable 리소스 or 이미지 URL + val author: String? = null +) + +val dummySavedBooks = listOf( + BookData("토마토 컬러면", R.drawable.bookcover_sample), + BookData("사슴", R.drawable.bookcover_sample), + BookData("토마토 컬러면", R.drawable.bookcover_sample), + BookData("사슴", R.drawable.bookcover_sample), + BookData("토마토 컬러면", R.drawable.bookcover_sample), + BookData("사슴", R.drawable.bookcover_sample), + BookData("토마토 컬러면", R.drawable.bookcover_sample), + BookData("사슴", R.drawable.bookcover_sample) +) +val dummyGroupBooks = listOf( + BookData("명작 읽기방", R.drawable.bookcover_sample), + BookData("또 다른 방", R.drawable.bookcover_sample), + BookData("명작 읽기방", R.drawable.bookcover_sample), + BookData("또 다른 방", R.drawable.bookcover_sample), + BookData("명작 읽기방", R.drawable.bookcover_sample), + BookData("또 다른 방", R.drawable.bookcover_sample), + BookData("명작 읽기방", R.drawable.bookcover_sample), + BookData("또 다른 방", R.drawable.bookcover_sample) +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/mock/GroupMakeRoomRequest.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/mock/GroupMakeRoomRequest.kt new file mode 100644 index 00000000..b41d0b8b --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/mock/GroupMakeRoomRequest.kt @@ -0,0 +1,15 @@ +package com.texthip.thip.ui.group.makeroom.mock + +import java.time.LocalDate + +data class GroupMakeRoomRequest( + val selectedBook: BookData?, + val genreIndex: Int, + val roomTitle: String, + val roomDescription: String, + val meetingStartDate: LocalDate, + val meetingEndDate: LocalDate, + val memberLimit: Int, + val isPrivate: Boolean, + val password: String = "" +) diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/mock/GroupMakeRoomUiState.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/mock/GroupMakeRoomUiState.kt new file mode 100644 index 00000000..7143a1d2 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/mock/GroupMakeRoomUiState.kt @@ -0,0 +1,56 @@ +package com.texthip.thip.ui.group.makeroom.mock + +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +data class GroupMakeRoomUiState( + val selectedBook: BookData? = null, + val showBookSearchSheet: Boolean = false, + val selectedGenreIndex: Int = -1, + val roomTitle: String = "", + val roomDescription: String = "", + val meetingStartDate: LocalDate = LocalDate.now(), + val meetingEndDate: LocalDate = LocalDate.now().plusDays(1), + val memberLimit: Int = 30, + val isPrivate: Boolean = false, + val password: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null +) { + // 유효성 검사 로직 + val isDurationValid: Boolean + get() { + val daysBetween = ChronoUnit.DAYS.between(meetingStartDate, meetingEndDate) + return daysBetween in 1..90 + } + + val isCountValid: Boolean + get() = memberLimit in 2..30 + + val isPasswordValid: Boolean + get() = !isPrivate || password.length == 4 + + val isFormValid: Boolean + get() = selectedBook != null && + selectedGenreIndex >= 0 && + roomTitle.isNotBlank() && + roomDescription.isNotBlank() && + isDurationValid && + isCountValid && + isPasswordValid + + // 서버 전송용 데이터로 변환 + fun toRequest(): GroupMakeRoomRequest { + return GroupMakeRoomRequest( + selectedBook = selectedBook, + genreIndex = selectedGenreIndex, + roomTitle = roomTitle.trim(), + roomDescription = roomDescription.trim(), + meetingStartDate = meetingStartDate, + meetingEndDate = meetingEndDate, + memberLimit = memberLimit, + isPrivate = isPrivate, + password = if (isPrivate) password else "" + ) + } +} \ 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..8211d6c4 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupMakeRoomScreen.kt @@ -0,0 +1,256 @@ +package com.texthip.thip.ui.group.makeroom.screen + +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.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.GroupSelectBook +import com.texthip.thip.ui.group.makeroom.component.MemberLimitPicker +import com.texthip.thip.ui.group.makeroom.component.SectionDivider +import com.texthip.thip.ui.group.makeroom.mock.BookData +import com.texthip.thip.ui.group.makeroom.mock.GroupMakeRoomRequest +import com.texthip.thip.ui.group.makeroom.mock.dummyGroupBooks +import com.texthip.thip.ui.group.makeroom.mock.dummySavedBooks +import com.texthip.thip.ui.group.makeroom.viewmodel.ApiResult +import com.texthip.thip.ui.group.makeroom.viewmodel.GroupCreateResponse +import com.texthip.thip.ui.group.makeroom.viewmodel.GroupMakeRoomViewModel +import com.texthip.thip.ui.group.makeroom.viewmodel.GroupRepository +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography + + +@Composable +fun GroupMakeRoomScreen( + viewModel: GroupMakeRoomViewModel, + onNavigateBack: () -> Unit, + onGroupCreated: () -> Unit, + modifier: Modifier = Modifier +) { + val uiState by viewModel.uiState.collectAsState() + val scrollState = rememberScrollState() + val genres = viewModel.genres + + // 에러 메시지 표시 + LaunchedEffect(uiState.errorMessage) { + uiState.errorMessage?.let { message -> + viewModel.clearError() + } + } + + Box { + Column( + modifier = modifier + .fillMaxSize() + .then(if (uiState.showBookSearchSheet) Modifier.blur(5.dp) else Modifier), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + InputTopAppBar( + title = stringResource(R.string.group_making_group), + isRightButtonEnabled = uiState.isFormValid && !uiState.isLoading, + onLeftClick = onNavigateBack, + onRightClick = { + viewModel.createGroup( + onSuccess = onGroupCreated, + onError = { /* 에러는 uiState.errorMessage로 처리 */ } + ) + } + ) + + Column( + modifier = Modifier + .verticalScroll(scrollState) + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.Top, + ) { + Spacer(modifier = Modifier.padding(top = 20.dp)) + + GroupSelectBook( + selectedBook = uiState.selectedBook, + onChangeBookClick = { viewModel.toggleBookSearchSheet(true) }, + onSelectBookClick = { viewModel.toggleBookSearchSheet(true) } + ) + + SectionDivider() + + 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 = uiState.selectedGenreIndex, + onSelect = viewModel::selectGenre + ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Text( + text = stringResource(R.string.group_genre_select_comment), + style = typography.info_r400_s12, + color = colors.NeonGreen + ) + } + + SectionDivider() + + GroupInputField( + title = stringResource(R.string.group_room_title), + hint = stringResource(R.string.group_room_title_hint), + value = uiState.roomTitle, + maxLength = 15, + onValueChange = viewModel::updateRoomTitle + ) + + SectionDivider() + + GroupInputField( + title = stringResource(R.string.group_room_explain), + hint = stringResource(R.string.group_room_explain_hint), + value = uiState.roomDescription, + onValueChange = viewModel::updateRoomDescription + ) + + SectionDivider() + + GroupRoomDurationPicker( + onDateRangeSelected = viewModel::setDateRange + ) + + SectionDivider() + + MemberLimitPicker( + selectedCount = uiState.memberLimit, + onCountSelected = viewModel::setMemberLimit + ) + + SectionDivider() + + Text( + text = stringResource(R.string.group_private_option), + 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 = stringResource(R.string.group_private_comment), + style = typography.menu_r400_s14_h24, + color = colors.White + ) + ToggleSwitchButton( + isChecked = uiState.isPrivate, + onToggleChange = viewModel::togglePrivate + ) + } + + if (uiState.isPrivate) { + Spacer(modifier = Modifier.height(12.dp)) + WarningTextField( + value = uiState.password, + onValueChange = viewModel::updatePassword, + hint = stringResource(R.string.group_password_hint), + showWarning = uiState.password.isNotEmpty() && uiState.password.length < 4, + warningMessage = stringResource(R.string.group_private_warning_message), + maxLength = 4, + isNumberOnly = true, + keyboardType = KeyboardType.NumberPassword + ) + } + + Spacer(modifier = Modifier.padding(top = 134.dp)) + } + } + + if (uiState.showBookSearchSheet) { + GroupBookSearchBottomSheet( + onDismiss = { viewModel.toggleBookSearchSheet(false) }, + onBookSelect = { book: BookData -> + viewModel.selectBook(book) + viewModel.toggleBookSearchSheet(false) + }, + onRequestBook = { + viewModel.toggleBookSearchSheet(false) + }, + savedBooks = dummySavedBooks, + groupBooks = dummyGroupBooks + ) + } + + // 로딩 인디케이터 + /*if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(colors.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = colors.NeonGreen) + } + }*/ + } +} + + +@Preview +@Composable +private fun GroupMakeRoomScreenPreview() { + // Preview용 MockViewModel 생성 + val mockViewModel = object : GroupMakeRoomViewModel(MockGroupRepository()) { + // 필요한 경우 Preview용 초기 상태 설정 + init { + // 예시: 미리 선택된 책이 있는 상태로 Preview + // selectBook(BookData(id = "1", title = "예시 책", author = "작가")) + // selectGenre(0) + } + } + + ThipTheme { + GroupMakeRoomScreen( + viewModel = mockViewModel, + onNavigateBack = { }, + onGroupCreated = { } + ) + } +} + +// Preview용 Mock Repository +class MockGroupRepository : GroupRepository { + override suspend fun createGroup(request: GroupMakeRoomRequest): ApiResult { + return ApiResult( + isSuccess = true, + data = GroupCreateResponse( + groupId = "mock_group_id", + groupName = "Mock Group" + ) + ) + } +} 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..f9ec8805 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/screen/GroupRegisterBookScreen.kt @@ -0,0 +1,66 @@ +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 + +@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.customer_center_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_1), + style = typography.copy_r400_s14, + color = colors.White + ) + Text( + text = stringResource(R.string.group_request_book_comment_2), + style = typography.copy_r400_s14, + color = colors.White + ) + } + } +} + +@Preview +@Composable +private fun GroupRegisterBookPreview() { + ThipTheme { + GroupRegisterBookScreen() + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/util/WheelPickerDisplayUtils.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/util/WheelPickerDisplayUtils.kt new file mode 100644 index 00000000..9a355d52 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/util/WheelPickerDisplayUtils.kt @@ -0,0 +1,30 @@ +package com.texthip.thip.ui.group.makeroom.util + +import kotlin.math.roundToInt + +object WheelPickerUtils { + @JvmStatic + fun getCircularIndex(index: Int, size: Int): Int { + return ((index % size) + size) % size + } + + @JvmStatic + fun normalizeOffset(offset: Float, itemSpacing: Float, size: Int, circular: Boolean): Float { + if (!circular) return offset + val total = size * itemSpacing + return ((offset % total) + total) % total + } + + @JvmStatic + fun offsetToIndex( + offset: Float, + itemSpacing: Float, + size: Int, + circular: Boolean + ): Int { + val normalized = if (circular) normalizeOffset(offset, itemSpacing, size, circular) else offset + val centerIndex = (-normalized / itemSpacing).roundToInt() + return if (circular) getCircularIndex(centerIndex, size) + else centerIndex.coerceIn(0, size - 1) + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt new file mode 100644 index 00000000..20e28599 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/group/makeroom/viewmodel/GroupMakeRoomViewModel.kt @@ -0,0 +1,125 @@ +package com.texthip.thip.ui.group.makeroom.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.ui.group.makeroom.mock.BookData +import com.texthip.thip.ui.group.makeroom.mock.GroupMakeRoomRequest +import com.texthip.thip.ui.group.makeroom.mock.GroupMakeRoomUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.LocalDate + +// 나중에 서버와 연동할 때 사용할 뷰모델 예시 +open class GroupMakeRoomViewModel( + private val groupRepository: GroupRepository // 의존성 주입 +) : ViewModel() { + + private val _uiState = MutableStateFlow(GroupMakeRoomUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val genres = listOf("문학", "과학·IT", "사회과학", "인문학", "예술") + + // 책 선택 + fun selectBook(book: BookData) { + _uiState.value = _uiState.value.copy(selectedBook = book) + } + + // 책 검색 시트 표시 상태 변경 + fun toggleBookSearchSheet(show: Boolean) { + _uiState.value = _uiState.value.copy(showBookSearchSheet = show) + } + + // 장르 선택 + fun selectGenre(index: Int) { + _uiState.value = _uiState.value.copy(selectedGenreIndex = index) + } + + // 방 제목 변경 + fun updateRoomTitle(title: String) { + _uiState.value = _uiState.value.copy(roomTitle = title) + } + + // 방 설명 변경 + fun updateRoomDescription(description: String) { + _uiState.value = _uiState.value.copy(roomDescription = description) + } + + // 모임 날짜 범위 설정 + fun setDateRange(startDate: LocalDate, endDate: LocalDate) { + _uiState.value = _uiState.value.copy( + meetingStartDate = startDate, + meetingEndDate = endDate + ) + } + + // 인원 수 설정 + fun setMemberLimit(count: Int) { + _uiState.value = _uiState.value.copy(memberLimit = count) + } + + // 비밀방 설정 + fun togglePrivate(isPrivate: Boolean) { + _uiState.value = _uiState.value.copy( + isPrivate = isPrivate, + password = if (!isPrivate) "" else _uiState.value.password + ) + } + + // 비밀번호 설정 + fun updatePassword(password: String) { + _uiState.value = _uiState.value.copy(password = password) + } + + // 그룹 생성 요청 + fun createGroup(onSuccess: () -> Unit, onError: (String) -> Unit) { + val currentState = _uiState.value + + if (!currentState.isFormValid) { + //onError("입력 정보를 확인해주세요") + return + } + + viewModelScope.launch { + try { + _uiState.value = currentState.copy(isLoading = true, errorMessage = null) + + val request = currentState.toRequest() + val result = groupRepository.createGroup(request) + + if (result.isSuccess) { + onSuccess() + } else { + //onError(result.message ?: "그룹 생성에 실패했습니다") + } + } catch (e: Exception) { + //onError("네트워크 오류가 발생했습니다: ${e.message}") + } finally { + _uiState.value = _uiState.value.copy(isLoading = false) + } + } + } + + // 에러 메시지 클리어 + fun clearError() { + _uiState.value = _uiState.value.copy(errorMessage = null) + } +} + +// Repository 예시 +interface GroupRepository { + suspend fun createGroup(request: GroupMakeRoomRequest): ApiResult +} + +// API 응답 클래스 예시 +data class ApiResult( + val isSuccess: Boolean, + val data: T? = null, + val message: String? = null +) + +data class GroupCreateResponse( + val groupId: String, + val groupName: String +) \ No newline at end of file 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..a6fb52fd 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 @@ -29,8 +30,7 @@ fun GroupRoomDeadlineSection( roomSections: List, onRoomClick: (GroupCardItemRoomData) -> Unit ) { - val cardWidth = 320.dp - val pageSpacing = 12.dp + val sideMargin = 30.dp val pagerState = rememberPagerState( initialPage = 0, @@ -44,20 +44,24 @@ fun GroupRoomDeadlineSection( ) { BoxWithConstraints( modifier = Modifier - .fillMaxWidth() - .height(588.dp), + .fillMaxWidth(), 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 +83,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,14 +131,6 @@ fun GroupRoomDeadlineSection( } } } - - SimplePagerIndicator( - pageCount = roomSections.size, - currentPage = pagerState.currentPage, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(top = 8.dp) - ) } } @@ -259,4 +255,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..9b5a2d6b 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 @@ -57,7 +57,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..3b2ab2a2 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 @@ -21,16 +21,20 @@ 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..7f9b4889 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 @@ -70,11 +70,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..e9746096 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 @@ -90,7 +90,7 @@ fun GroupScreen( roomSections = roomSections, onRoomClick = { viewModel.onRoomCardClick(it) } ) - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(102.dp)) } // 오른쪽 하단 FAB FloatingButton( diff --git a/app/src/main/java/com/texthip/thip/ui/myPage/screen/MyPageScreen.kt b/app/src/main/java/com/texthip/thip/ui/myPage/screen/MyPageScreen.kt index e746dfca..e5f3d666 100644 --- a/app/src/main/java/com/texthip/thip/ui/myPage/screen/MyPageScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/myPage/screen/MyPageScreen.kt @@ -109,7 +109,7 @@ fun MyPageScreen( ) MenuItemButton( text = stringResource(R.string.notification_settings), - icon = painterResource(R.drawable.ic_notice), + icon = painterResource(R.drawable.ic_notification), contentColor = colors.White, backgroundColor = colors.DarkGrey02, hasRightIcon = true, diff --git a/app/src/main/res/drawable/ic_notice.xml b/app/src/main/res/drawable/ic_notice.xml index 4cec80dd..5ccd9f64 100644 --- a/app/src/main/res/drawable/ic_notice.xml +++ b/app/src/main/res/drawable/ic_notice.xml @@ -4,14 +4,14 @@ android:viewportWidth="25" android:viewportHeight="24"> + + diff --git a/app/src/main/res/drawable/ic_x_circle_grey.xml b/app/src/main/res/drawable/ic_x_circle_grey.xml new file mode 100644 index 00000000..300f0c30 --- /dev/null +++ b/app/src/main/res/drawable/ic_x_circle_grey.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3b28e70..1880926f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -166,4 +166,42 @@ 내 기록 페이지별 보기 총평 보기 + + + 모임 만들기 + 저장한 책 + 모임 책 + 책 제목, 저자 검색 + 책 등록하기 + 현재 등록된 책이 아닙니다.\n원하시는 책을 신청해주세요. + 책 신청 + 이메일로 책 제목, 출판사를 보내주시면 + 빠른 시일내로 책을 추가해드릴게요! + 책 선택 + 책 장르 + 책을 가장 잘 설명하는 장르를 하나 골라주세요 + 방 제목 + 방 제목을 입력해주세요 + 한 줄 소개 + 방에 대한 짧은 소개글을 작성해주세요 + 모임 활동기간 + 모임 활동 기간은 최대 3개월까지 설정 가능합니다. + (선택된 기간: %1$d일) + 인원 제한 + 모집 인원은 최대 30명까지 입력할 수 있습니다. + 명의 독서 메이트를 모집합니다 + 1개만 선택 가능합니다. + %1$s 저 + 4자리 숫자로 입장 비밀번호를 설정 + + + + 공개 설정 + 비공개로 설정하기 + 4자리 숫자를 입력해주세요. + %1$d월 %2$d일 자정에 자동으로 모집 마감되고 활동이 가능합니다. + 검색해서 찾기 + 모임방 활동이 시작되면, 독서메이트 모집이 자동으로 종료돼요. + %1$d / %2$d + \ No newline at end of file