diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 0cd67c48..f2b3a718 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -10,7 +10,7 @@ dependencies { implementation(projects.core.model) implementation(libs.flexible.bottomsheet) implementation(libs.kizitonwose.calendar.compose) - + implementation(projects.core.common) implementation(projects.domain.weather) implementation(projects.domain.study) implementation(projects.domain.alert) diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/bottomsheet/LocationBottomSheet.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/bottomsheet/LocationBottomSheet.kt new file mode 100644 index 00000000..7311819e --- /dev/null +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/bottomsheet/LocationBottomSheet.kt @@ -0,0 +1,376 @@ +package com.umcspot.spot.designsystem.component.bottomsheet + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.umcspot.spot.common.location.LocationRow +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.shapes.SpotShapes +import com.umcspot.spot.designsystem.theme.B100 +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.G200 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationBottomSheet( + visible: Boolean, + query: String, + results: List, + onQueryChange: (String) -> Unit, + onDismiss: () -> Unit, + selected: List, + onAddSelected: (String) -> Unit, + onRemoveSelected: (String) -> Unit +) { + if (!visible) return + + val keyboard = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + val blurFocusRequester = remember { FocusRequester() } + var isFocused by remember { mutableStateOf(false) } + + val density = LocalDensity.current + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + + val sheetHeight = screenHeightDp(533.dp) + val scope = rememberCoroutineScope() + val sheetOffset = remember { Animatable(with(density) { screenHeight.toPx() }) } + val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + + fun animateAndDismiss() { + scope.launch { + blurFocusRequester.requestFocus() + keyboard?.hide() + sheetOffset.animateTo( + targetValue = with(density) { screenHeight.toPx() }, + animationSpec = tween(250) + ) + onDismiss() + } + } + + LaunchedEffect(visible) { + if (visible) { + val screenHeightPx = with(density) { screenHeight.toPx() } + val sheetHeightPx = with(density) { sheetHeight.toPx() } + val openY = screenHeightPx - sheetHeightPx + sheetOffset.snapTo(screenHeightPx) + sheetOffset.animateTo(openY, animationSpec = tween(300)) + } + } + if (visible) { + Dialog( + onDismissRequest = { animateAndDismiss() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + BackHandler(enabled = true) { animateAndDismiss() } + + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .matchParentSize() + .background(SpotTheme.colors.black.copy(alpha = 0.4f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + animateAndDismiss() + } + ) + + Box( + Modifier + .fillMaxWidth() + .height(sheetHeight) + .offset { IntOffset(0, sheetOffset.value.roundToInt()) } + .clip(SpotShapes.SoftTop) + .background(SpotTheme.colors.white) + .imePadding() + .focusRequester(blurFocusRequester) + .focusable() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + + blurFocusRequester.requestFocus() + keyboard?.hide() + } + ) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = screenWidthDp(17.dp)) + ) { + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = screenHeightDp(16.dp)), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(Modifier.width(screenWidthDp(24.dp))) + Text( + text = "스터디 지역", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.black + ) + IconButton(onClick = { animateAndDismiss() }) { + Icon( + painter = painterResource(R.drawable.dismiss), + contentDescription = "닫기", + ) + } + } + + Spacer(Modifier.height(screenHeightDp(18.dp))) + + Text( + text = "스터디를 진행하고 싶은 지역을 추가해주세요.", + style = SpotTheme.typography.h3, + color = SpotTheme.colors.black + ) + + Spacer(Modifier.height(screenHeightDp(6.dp))) + + Text( + text = "최대 3개까지 추가할 수 있어요", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.gray400 + ) + + Spacer(Modifier.height(screenHeightDp(20.dp))) + + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + textStyle = SpotTheme.typography.h5, + placeholder = { + Text( + text = "OO시, OO구, OO동", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.gray400 + ) + }, + trailingIcon = { + IconButton( + onClick = { + if (isFocused) { + blurFocusRequester.requestFocus() + keyboard?.hide() + } else { + focusRequester.requestFocus() + keyboard?.show() + } + }) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = "검색", + modifier = Modifier.size(18.dp) + ) + } + }, + singleLine = true, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = SpotTheme.colors.B500, + unfocusedBorderColor = SpotTheme.colors.gray400, + cursorColor = SpotTheme.colors.B500 + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { fs -> + isFocused = fs.isFocused + if (isFocused) keyboard?.show() + } + ) + + SelectedChips( + items = selected, + onRemove = onRemoveSelected + ) + + if (results.isNotEmpty()) { + val isMaxSelected = selected.size >= 3 + + HorizontalDivider(thickness = 0.5.dp, color = SpotTheme.colors.G200) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = navBarPadding) + .background(SpotTheme.colors.white), + ) { + itemsIndexed(results, key = { _, row -> row.code }) { index, row -> + val isAlreadySelected = selected.contains(row.name) + ListItem( + headlineContent = { + Text( + text = row.name, + style = SpotTheme.typography.h5, + maxLines = 1, + color = if (isMaxSelected && !isAlreadySelected) { + SpotTheme.colors.gray400 + } else { + LocalContentColor.current + } + ) + }, + colors = ListItemDefaults.colors( + containerColor = SpotTheme.colors.white + ), + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = !isMaxSelected || isAlreadySelected, + onClick = { + if (!isAlreadySelected) { + onAddSelected(row.name) + } + } + ) + ) + + HorizontalDivider( + thickness = 0.5.dp, + color = SpotTheme.colors.G200 + ) + } + } + } else { + if (query.isNotBlank()) { + Text( + + text = "검색 결과가 없습니다.", + color = SpotTheme.colors.gray400, + style = SpotTheme.typography.small_300, + modifier = Modifier.padding(top = 14.dp), + ) + } + } + } + } + } + } + } +} + + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SelectedChips( + items: List, + onRemove: (String) -> Unit +) { + if (items.isEmpty()) return + + val scrollState = rememberScrollState() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = screenHeightDp(13.dp)) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(7.dp)) + ) { + items.forEach { name -> + AssistChip( + onClick = {}, + label = { + Text( + name, + style = SpotTheme.typography.small_400 + ) + }, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.dismiss), + contentDescription = "삭제", + tint = SpotTheme.colors.B500, + modifier = Modifier + .size(14.dp) + .clickable { onRemove(name) } + ) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = SpotTheme.colors.B100, + labelColor = SpotTheme.colors.B500 + ), + border = BorderStroke(1.dp, SolidColor(SpotTheme.colors.B100)), + shape = RoundedCornerShape(6.dp) + ) + } + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageTextButton.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageTextButton.kt index fb2893f3..58ca5dc6 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageTextButton.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageTextButton.kt @@ -5,15 +5,15 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState 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.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,36 +22,31 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.Dp.Companion.Unspecified -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.isSpecified -import androidx.compose.ui.unit.sp import com.umcspot.spot.designsystem.R import com.umcspot.spot.designsystem.shapes.ShapeBox -import com.umcspot.spot.designsystem.shapes.SpotShapes import com.umcspot.spot.designsystem.theme.B100 import com.umcspot.spot.designsystem.theme.B200 import com.umcspot.spot.designsystem.theme.B400 +import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.Black import com.umcspot.spot.designsystem.theme.G400 import com.umcspot.spot.designsystem.theme.SpotTheme import com.umcspot.spot.designsystem.theme.White +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp data class MultiButtonColors( val bg: Color, val icon: Color, - val text : Color, + val text: Color, ) enum class MultiButtonState( @@ -74,12 +69,12 @@ enum class MultiButtonState( pressed = MultiButtonColors( bg = B200, icon = B400, - text = B400 + text = B500, ), selected = MultiButtonColors( bg = B100, icon = B400, - text = B400 + text = B500 ) ) } @@ -90,173 +85,118 @@ fun MultiButtonState.resolveColors( checked: Boolean ): MultiButtonColors = when { !enabled -> disabled - checked -> selected + checked -> selected isPressed -> pressed else -> normal } -/** 이미지 전용 버튼 크기 토큰 */ -enum class MultiButtonSize( - val size: Dp, - val icon: Dp, - val text: TextUnit -) { - XL(56.dp, 28.dp, 15.sp), - L (52.dp, 24.dp, 15.sp), - M (48.dp, 22.dp, 15.sp), - S (44.dp, 20.dp, 15.sp), - XS(40.dp, 18.dp, 15.sp); -} -@Composable -fun MultiButtonSize.textStyle(): TextStyle = when (this) { - MultiButtonSize.XL, MultiButtonSize.L -> SpotTheme.typography.h4 - MultiButtonSize.M -> SpotTheme.typography.h4 - MultiButtonSize.S, MultiButtonSize.XS -> SpotTheme.typography.h5 -} - -private val MultiButtonSize.horizontalPadding: Dp get() = when (this) { - MultiButtonSize.XL, MultiButtonSize.L -> 16.dp - MultiButtonSize.M -> 14.dp - MultiButtonSize.S -> 12.dp - MultiButtonSize.XS -> 10.dp -} -private val MultiButtonSize.gap: Dp get() = when (this) { - MultiButtonSize.XL, MultiButtonSize.L -> 8.dp - MultiButtonSize.M, MultiButtonSize.S -> 6.dp - MultiButtonSize.XS -> 4.dp -} - -@Composable -fun MultiButtonSize.shape(): Shape = SpotShapes.Hard - @Composable fun MultiButton( text: String, modifier: Modifier = Modifier, - size: MultiButtonSize = MultiButtonSize.M, enabled: Boolean = true, state: MultiButtonState = MultiButtonState.XOUTLINEState, - // 토글 유지 checked: Boolean = false, - width : Dp = Unspecified, - // ✅ 단일 콜백: 컴포넌트가 계산한 newChecked 전달 onClick: (newChecked: Boolean) -> Unit, - // 아이콘 지정: painter가 우선 painter: Painter? = null, - - // 아이콘 틴트 사용 여부 (true면 state의 icon 색상 사용) tintIcon: Boolean = false, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val isPressed by interactionSource.collectIsPressedAsState() - val widthMod = if (width.isSpecified) Modifier.width(width) else Modifier.fillMaxWidth() - val colors = state.resolveColors( enabled = enabled, isPressed = isPressed, checked = checked ) - // 접근성: 토글 가능 시 selected 노출 - val clickableModifier = modifier - .then(widthMod) - .heightIn(min = size.size) - .semantics { - role = Role.Button - selected = checked - } - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = null - ) { - val newChecked = checked - onClick(newChecked) - } - - // 컨테이너: 보더 없이 배경만 - ShapeBox( - shape = SpotShapes.Hard, - color = colors.bg, - borderWidth = 0.dp, - borderColor = null, - modifier = clickableModifier.height(size.size) // ✅ 동일 로직 적용 - + Box( + modifier = modifier + .semantics { + role = Role.Button + selected = checked + } + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null + ) { + onClick(!checked) + } ) { - Row( + ShapeBox( + shape = RoundedCornerShape(10.dp), + color = colors.bg, + borderWidth = 0.dp, + borderColor = null, modifier = Modifier - .height(size.size) - .fillMaxWidth() - .padding(horizontal = size.horizontalPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start + .width(screenWidthDp(156.dp)) + .height(screenHeightDp(43.dp)) ) { - painter?.let { - if (tintIcon) { - Icon( - painter = it, - contentDescription = null, - tint = colors.icon, - modifier = Modifier.size(size.icon) - ) - } else { - Image( - painter = it, - contentDescription = null, - modifier = Modifier.size(size.icon) + Box( + modifier = Modifier.matchParentSize(), + contentAlignment = Alignment.CenterStart + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = screenWidthDp(8.dp)) + ) { + painter?.let { + val iconModifier = Modifier.size(screenHeightDp(33.dp)) + if (tintIcon) { + Icon( + painter = it, + contentDescription = null, + tint = colors.icon, + modifier = iconModifier + ) + } else { + Image( + painter = it, + contentDescription = null, + modifier = iconModifier + ) + } + Spacer(Modifier.width(screenWidthDp(8.dp))) + } + Text( + text = text, + style = SpotTheme.typography.h4, + color = colors.text, + maxLines = 1 ) } - Spacer(Modifier.width(size.gap)) } - - // 텍스트 - Text( - text = text, - style = size.textStyle(), - fontSize = size.text, - color = colors.text, - maxLines = 1 - ) } } } -@Composable -fun MultiButtonM( - text: String, - checked: Boolean, - onClick: (Boolean) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - width : Dp = Unspecified, - state: MultiButtonState = MultiButtonState.XOUTLINEState, - painter: Painter? = null, - tintIcon: Boolean = false -) = MultiButton( - text = text, - modifier = modifier, - size = MultiButtonSize.M, - enabled = enabled, - state = state, - width = width, - checked = checked, - onClick = onClick, - painter = painter, - tintIcon = tintIcon -) - @Preview(showBackground = true) @Composable fun MultiButtonPreview() { SpotTheme { - MultiButtonM( - text = "온라인", - onClick = {}, - modifier = Modifier.padding(10.dp), - checked = false, - width = 156.dp, - painter = painterResource(R.drawable.language), - tintIcon = false - ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MultiButton( + text = "어학", + onClick = {}, + checked = true, + painter = painterResource(R.drawable.language), + ) + MultiButton( + text = "자격증", + onClick = {}, + checked = false, + painter = painterResource(R.drawable.license), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MultiButton( + text = "프로젝트", + onClick = {}, + checked = false, + painter = painterResource(R.drawable.project), + ) + } + } } -} \ No newline at end of file +} diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/SocialLoginButton.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SocialLoginButton.kt similarity index 98% rename from core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/SocialLoginButton.kt rename to core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SocialLoginButton.kt index 5e6572b6..556bb3db 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/SocialLoginButton.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SocialLoginButton.kt @@ -1,4 +1,4 @@ -package com.umcspot.spot.designsystem.component +package com.umcspot.spot.designsystem.component.button import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -22,7 +22,6 @@ import androidx.compose.ui.unit.sp import com.umcspot.spot.designsystem.theme.* import com.umcspot.spot.designsystem.R import com.umcspot.spot.designsystem.shapes.SpotShapes -import com.umcspot.spot.designsystem.theme.SpotTypography // ===== 공통 베이스 버튼 ===== @Composable diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityThemeSection.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityThemeSection.kt new file mode 100644 index 00000000..a60eda10 --- /dev/null +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityThemeSection.kt @@ -0,0 +1,102 @@ +package com.umcspot.spot.designsystem.component.study.section + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.button.MultiButton +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun ActivityThemeSection( + activityTheme: StudyTheme?, + onSelect: (StudyTheme) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(14.dp)), + verticalArrangement = Arrangement.spacedBy(screenHeightDp(16.dp)) + ) { + StudyTheme.entries.forEach { theme -> + val iconRes = when (theme) { + StudyTheme.LANGUAGE -> painterResource(R.drawable.language) + StudyTheme.LICENSE -> painterResource(R.drawable.license) + StudyTheme.EMPLOYMENT -> painterResource(R.drawable.employment) + StudyTheme.DISCUSSION -> painterResource(R.drawable.discussion) + StudyTheme.NEWS -> painterResource(R.drawable.news) + StudyTheme.SELFSTUDY -> painterResource(R.drawable.self_study) + StudyTheme.PROJECT -> painterResource(R.drawable.project) + StudyTheme.CONTEST -> painterResource(R.drawable.contest) + StudyTheme.MAJOR -> painterResource(R.drawable.major) + StudyTheme.ETC -> painterResource(R.drawable.resource_else) + } + + MultiButton( + text = theme.title, + painter = iconRes, + checked = activityTheme == theme, + onClick = { onSelect(theme) }, + ) + } + } + } +} + +@Composable +fun ActivityThemeSection( + selectedThemes: ImmutableList, + onSelect: (StudyTheme) -> Unit, + modifier: Modifier = Modifier +) { + val themesInRows = StudyTheme.entries.chunked(2) + val isMaxSelected = selectedThemes.size >= 3 + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(screenHeightDp(16.dp)) + ) { + themesInRows.forEach { themesInRow -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(14.dp)) + ) { + themesInRow.forEach { theme -> + val iconRes = getIconForTheme(theme) + val isChecked = selectedThemes.contains(theme) + + MultiButton( + text = theme.title, + painter = iconRes, + checked = isChecked, + enabled = !isMaxSelected || isChecked, + onClick = { onSelect(theme) }, + ) + } + } + } + } +} +@Composable +private fun getIconForTheme(theme: StudyTheme) = when (theme) { + StudyTheme.LANGUAGE -> painterResource(R.drawable.language) + StudyTheme.LICENSE -> painterResource(R.drawable.license) + StudyTheme.EMPLOYMENT -> painterResource(R.drawable.employment) + StudyTheme.DISCUSSION -> painterResource(R.drawable.discussion) + StudyTheme.NEWS -> painterResource(R.drawable.news) + StudyTheme.SELFSTUDY -> painterResource(R.drawable.self_study) + StudyTheme.PROJECT -> painterResource(R.drawable.project) + StudyTheme.CONTEST -> painterResource(R.drawable.contest) + StudyTheme.MAJOR -> painterResource(R.drawable.major) + StudyTheme.ETC -> painterResource(R.drawable.resource_else) +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityTypeSection.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityTypeSection.kt new file mode 100644 index 00000000..0b2f0bc0 --- /dev/null +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityTypeSection.kt @@ -0,0 +1,40 @@ +package com.umcspot.spot.designsystem.component.study.section + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.button.MultiButton +import com.umcspot.spot.model.ActivityType +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun ActivityTypeSection( + activityType: ActivityType?, + onSelect: (ActivityType) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(14.dp)) + ) { + ActivityType.entries.forEach { type -> + val iconRes = when (type) { + ActivityType.ONLINE -> painterResource(R.drawable.online) + ActivityType.OFFLINE -> painterResource(R.drawable.offline) + } + + MultiButton( + text = type.label, + painter = iconRes, + checked = activityType == type, + onClick = { + onSelect(type) + }, + ) + } + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Color.kt index 9f518473..0bf0c88d 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Color.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -/****************** base color ******************/ + val Purple80 = Color(0xFFD0BCFF) val PurpleGrey80 = Color(0xFFCCC2DC) val Pink80 = Color(0xFFEFB8C8) @@ -16,24 +16,15 @@ val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) - -/****************** SPOT COLOR ******************/ - -/** Primary **/ - val B500 = Color(0xFF005BFF) val B400 = Color(0xFF337BFF) -/** Secondary **/ - val Y400 = Color(0xFFFD8653) val R500 = Color(0xFFF34343) val B200 = Color(0xFFD3E1FD) val B100 = Color(0xFFEDF4FF) -/** Gray Scale **/ - val Black = Color(0xFF1E1E1E) val G500 = Color(0xFF4F4F56) val G400 = Color(0xFF8F8F99) @@ -46,44 +37,26 @@ val NaverGreen = Color(0xFF03CF5D) val KakaoYellow = Color(0xFFFFEC00) val KakaoText = Color(0xFF3C1E1E) -/** Gradiant **/ - val BlueGradient = Brush.linearGradient( colors = listOf(B400, B500), - start = Offset(0f, 0f), // A 지점 - end = Offset(1000f, 500f) // B 지점 (대각선) + start = Offset(0f, 0f), + end = Offset(1000f, 500f) ) val GrayGradient = Brush.linearGradient( colors = listOf(G300, G500), - start = Offset(0f, 0f), // A 지점 - end = Offset(1000f, 500f) // B 지점 + start = Offset(0f, 0f), + end = Offset(1000f, 500f) ) -/* - Gradiant 적용 예시 - - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .background(BlueGradient) // 또는 GrayGradient - ) - - */ - - -// --- Primary / Blue --- val SpotColors.B500: Color get() = primary val SpotColors.B400: Color get() = primaryStrong val SpotColors.B200: Color get() = primarySoft val SpotColors.B100: Color get() = primarySoftest -// --- Secondary / Error --- val SpotColors.Y400: Color get() = secondary val SpotColors.R500: Color get() = error -// --- Gray Scale --- val SpotColors.Black: Color get() = black val SpotColors.White: Color get() = white val SpotColors.G500: Color get() = gray500 @@ -91,24 +64,17 @@ val SpotColors.G400: Color get() = gray400 val SpotColors.G300: Color get() = gray300 val SpotColors.G200: Color get() = gray200 val SpotColors.G100: Color get() = gray100 +val SpotColors.Default: Color get() = default -// --- Brand etc. --- val SpotColors.NaverGreen: Color get() = naverGreen val SpotColors.KakaoYellow: Color get() = kakaoYellow val SpotColors.KakaoText: Color get() = kakaoText -// --- Alpha precomputed (이름을 토큰처럼 노출) --- val SpotColors.BlackA80: Color get() = blackA80 val SpotColors.BlackA50: Color get() = blackA50 val SpotColors.WhiteA50: Color get() = whiteA50 val SpotColors.WhiteA10: Color get() = whiteA10 -/* ----------------------------------------------------------- - * 테마 인지형 그라디언트 - * - 기존 정적 BlueGradient/GrayGradient를 대체 가능 - * - 다크/동적 테마 대응 - * ----------------------------------------------------------- */ - fun SpotColors.blueGradient( start: Offset = Offset(0f, 0f), end: Offset = Offset(1000f, 500f) @@ -121,61 +87,62 @@ fun SpotColors.grayGradient( @Stable class SpotColors( - primary: Color, // 주 브랜드 컬러(진) - primaryStrong: Color, // 보조 진한 파랑 - primarySoft: Color, // 연파랑 1 - primarySoftest: Color, // 연파랑 2 - secondary: Color, // 포인트(오렌지/옐로우) - error: Color, // 에러 + primary: Color, + primaryStrong: Color, + primarySoft: Color, + primarySoftest: Color, + secondary: Color, + error: Color, gray500: Color, gray400: Color, gray300: Color, gray200: Color, gray100: Color, + default: Color, black: Color, white: Color, naverGreen: Color, kakaoYellow: Color, kakaoText: Color, - // 자주 쓰는 알파 색상은 내부에서 계산해둠 blackAlpha80: Color, blackAlpha50: Color, whiteAlpha50: Color, whiteAlpha10: Color, isLight: Boolean ) { - var primary by mutableStateOf(primary); private set + var primary by mutableStateOf(primary); private set var primaryStrong by mutableStateOf(primaryStrong); private set - var primarySoft by mutableStateOf(primarySoft); private set + var primarySoft by mutableStateOf(primarySoft); private set var primarySoftest by mutableStateOf(primarySoftest); private set - var secondary by mutableStateOf(secondary); private set - var error by mutableStateOf(error); private set + var secondary by mutableStateOf(secondary); private set + var error by mutableStateOf(error); private set - var gray500 by mutableStateOf(gray500); private set - var gray400 by mutableStateOf(gray400); private set - var gray300 by mutableStateOf(gray300); private set - var gray200 by mutableStateOf(gray200); private set - var gray100 by mutableStateOf(gray100); private set + var gray500 by mutableStateOf(gray500); private set + var gray400 by mutableStateOf(gray400); private set + var gray300 by mutableStateOf(gray300); private set + var gray200 by mutableStateOf(gray200); private set + var gray100 by mutableStateOf(gray100); private set + var default by mutableStateOf(default); private set - var black by mutableStateOf(black); private set - var white by mutableStateOf(white); private set + var black by mutableStateOf(black); private set + var white by mutableStateOf(white); private set - var naverGreen by mutableStateOf(naverGreen); private set - var kakaoYellow by mutableStateOf(kakaoYellow); private set - var kakaoText by mutableStateOf(kakaoText); private set + var naverGreen by mutableStateOf(naverGreen); private set + var kakaoYellow by mutableStateOf(kakaoYellow); private set + var kakaoText by mutableStateOf(kakaoText); private set - var blackA80 by mutableStateOf(blackAlpha80); private set - var blackA50 by mutableStateOf(blackAlpha50); private set - var whiteA50 by mutableStateOf(whiteAlpha50); private set - var whiteA10 by mutableStateOf(whiteAlpha10); private set + var blackA80 by mutableStateOf(blackAlpha80); private set + var blackA50 by mutableStateOf(blackAlpha50); private set + var whiteA50 by mutableStateOf(whiteAlpha50); private set + var whiteA10 by mutableStateOf(whiteAlpha10); private set var isLight by mutableStateOf(isLight) fun copy() = SpotColors( primary, primaryStrong, primarySoft, primarySoftest, secondary, error, - gray500, gray400, gray300, gray200, gray100, + gray500, gray400, gray300, gray200, gray100, default, black, white, naverGreen, kakaoYellow, kakaoText, blackA80, blackA50, whiteA50, whiteA10, @@ -196,6 +163,7 @@ class SpotColors( gray300 = colors.gray300 gray200 = colors.gray200 gray100 = colors.gray100 + default = colors.default black = colors.black white = colors.white @@ -213,7 +181,6 @@ class SpotColors( } } -/** 상단 토큰을 그대로 꽂아넣는 라이트 팔레트 */ fun SpotDayColors(): SpotColors = SpotColors( primary = B500, primaryStrong = B400, @@ -228,6 +195,7 @@ fun SpotDayColors(): SpotColors = SpotColors( gray300 = G300, gray200 = G200, gray100 = G100, + default = G300, black = Black, white = White, @@ -236,11 +204,10 @@ fun SpotDayColors(): SpotColors = SpotColors( kakaoYellow = KakaoYellow, kakaoText = KakaoText, - // 알파 컬러는 여기서 계산 blackAlpha80 = Black.copy(alpha = 0.8f), blackAlpha50 = Black.copy(alpha = 0.5f), whiteAlpha50 = White.copy(alpha = 0.5f), whiteAlpha10 = White.copy(alpha = 0.1f), isLight = true -) +) \ No newline at end of file diff --git a/core/designsystem/src/main/res/drawable/dismiss.xml b/core/designsystem/src/main/res/drawable/dismiss.xml index 1a374c48..405ad0dd 100644 --- a/core/designsystem/src/main/res/drawable/dismiss.xml +++ b/core/designsystem/src/main/res/drawable/dismiss.xml @@ -1,9 +1,9 @@ + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + android:pathData="M3.666,3.795L3.726,3.725C3.832,3.619 3.973,3.554 4.122,3.543C4.271,3.532 4.419,3.575 4.54,3.664L4.61,3.725L10.001,9.115L15.393,3.724C15.451,3.664 15.52,3.617 15.596,3.584C15.672,3.551 15.754,3.534 15.837,3.533C15.92,3.533 16.003,3.548 16.079,3.58C16.156,3.611 16.226,3.658 16.285,3.716C16.343,3.775 16.39,3.845 16.421,3.922C16.452,3.999 16.468,4.081 16.467,4.164C16.467,4.247 16.449,4.329 16.417,4.405C16.384,4.481 16.336,4.55 16.276,4.608L10.886,10L16.277,15.391C16.383,15.497 16.447,15.638 16.458,15.787C16.469,15.936 16.426,16.084 16.337,16.205L16.276,16.275C16.171,16.381 16.03,16.445 15.881,16.456C15.732,16.467 15.583,16.424 15.463,16.336L15.393,16.275L10.001,10.884L4.61,16.275C4.492,16.389 4.334,16.452 4.17,16.451C4.006,16.449 3.849,16.383 3.734,16.267C3.618,16.152 3.552,15.995 3.551,15.831C3.549,15.667 3.613,15.509 3.726,15.391L9.117,10L3.726,4.608C3.62,4.502 3.556,4.362 3.545,4.212C3.534,4.063 3.577,3.915 3.666,3.795Z" + android:fillColor="#4F4F56"/> diff --git a/core/designsystem/src/main/res/drawable/ic_location.xml b/core/designsystem/src/main/res/drawable/ic_location.xml new file mode 100644 index 00000000..b0b2a04d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_location.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/main/src/main/java/com/umcspot/spot/landing/LandingScreen.kt b/feature/main/src/main/java/com/umcspot/spot/landing/LandingScreen.kt index 1c8cf980..cb307cc2 100644 --- a/feature/main/src/main/java/com/umcspot/spot/landing/LandingScreen.kt +++ b/feature/main/src/main/java/com/umcspot/spot/landing/LandingScreen.kt @@ -1,13 +1,9 @@ package com.umcspot.spot.landing -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image -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.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -15,48 +11,22 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.FabPosition -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface 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.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.VerticalAlignmentLine import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.compose.currentBackStackEntryAsState -import com.umcspot.spot.alert.navigation.Alert -import com.umcspot.spot.alert.navigation.AppliedAlert -import com.umcspot.spot.alert.navigation.navigateToAlert import com.umcspot.spot.designsystem.R -import com.umcspot.spot.designsystem.component.FloatingMultipleButton -import com.umcspot.spot.designsystem.component.FloatingToUpButton -import com.umcspot.spot.designsystem.component.KakaoLoginButton -import com.umcspot.spot.designsystem.component.KakaoStartButton -import com.umcspot.spot.designsystem.component.NaverStartButton -import com.umcspot.spot.designsystem.component.appBar.AppBarHome -import com.umcspot.spot.designsystem.component.appBar.BackTopBar +import com.umcspot.spot.designsystem.component.button.KakaoStartButton +import com.umcspot.spot.designsystem.component.button.NaverStartButton import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.SpotTheme -import com.umcspot.spot.main.MainNavHost -import com.umcspot.spot.main.MainNavTab -import com.umcspot.spot.main.MainNavigator -import com.umcspot.spot.main.component.MainBottomBar -import com.umcspot.spot.main.rememberMainNavigator -import kotlinx.collections.immutable.toImmutableList @Composable fun LandingScreen( diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt index 38619f17..d5c03879 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt @@ -44,16 +44,15 @@ fun MainScreen( Scaffold( topBar = { if (!navigator.isInLanding()) { - if (navigator.showBackTopBar()) { - // 라우트에 따라 타이틀 분기(선택) - val title = - when { - dest?.hasRoute(Alert::class) == true -> "알림" - dest?.hasRoute(AppliedAlert::class) == true -> "신청한 알림" - dest?.hasRoute(RecruitingFilter::class) == true -> "모집중인 스터디" - dest?.hasRoute(RegisterStudy::class) == true -> "스터디 만들기" - else -> "" - } + if (dest?.hasRoute(RegisterStudy::class) == true) { + } + else if (navigator.showBackTopBar()) { + val title = when { + dest?.hasRoute(Alert::class) == true -> "알림" + dest?.hasRoute(AppliedAlert::class) == true -> "신청한 알림" + dest?.hasRoute(RecruitingFilter::class) == true -> "모집중인 스터디" + else -> "" + } BackTopBar( title = title, onBackClick = { navController.popBackStack() }, @@ -79,7 +78,7 @@ fun MainScreen( showMultiple = navigator.showMultipleFab(), onClickMultiple = { /* TODO */ }, - spacing = 12.dp, // floatingButton 사이 간격 + spacing = 12.dp, ) }, bottomBar = { diff --git a/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationBottomSheet.kt b/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationBottomSheet.kt deleted file mode 100644 index 96a922cd..00000000 --- a/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationBottomSheet.kt +++ /dev/null @@ -1,318 +0,0 @@ -import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.umcspot.spot.common.location.LocationRow -import com.umcspot.spot.designsystem.R -import com.umcspot.spot.designsystem.shapes.SpotShapes -import com.umcspot.spot.designsystem.theme.B100 -import com.umcspot.spot.designsystem.theme.B400 -import com.umcspot.spot.designsystem.theme.B500 -import com.umcspot.spot.designsystem.theme.SpotTheme -import kotlinx.coroutines.launch -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PreferLocationBottomSheet( - contentPadding : PaddingValues, - visible: Boolean, - query: String, - results: List, // ✅ 결과 리스트 전달받음 - onQueryChange: (String) -> Unit, - onDismiss: () -> Unit, - selected: List, // ✅ 현재 선택된 지역 칩들 - onAddSelected: (String) -> Unit, // ✅ 선택 추가 - onRemoveSelected: (String) -> Unit // ✅ 선택 제거, -) { - if (!visible) return - - val keyboard = LocalSoftwareKeyboardController.current - val focusRequester = remember { FocusRequester() } - val blurFocusRequester = remember { FocusRequester() } // 🔑 바깥 클릭 시 포커스 이동용 - var isFocused by remember { mutableStateOf(false) } - - val density = LocalDensity.current - val screenHeight = LocalConfiguration.current.screenHeightDp.dp - val sheetHeight = 533.dp - val scope = rememberCoroutineScope() - val sheetOffset = remember { Animatable(with(density) { screenHeight.toPx() }) } - val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - - - LaunchedEffect(Unit) { - val screenHeightPx = with(density) { screenHeight.toPx() } - val sheetHeightPx = with(density) { sheetHeight.toPx() } - val openY = screenHeightPx - sheetHeightPx // 👈 바닥에 딱 붙이기 - sheetOffset.animateTo(openY, animationSpec = tween(300)) - } - - fun animateAndDismiss() { - scope.launch { - sheetOffset.animateTo( - targetValue = with(density) { screenHeight.toPx() }, - animationSpec = tween(250) - ) - onDismiss() - } - } - - Dialog( - onDismissRequest = { animateAndDismiss() }, - properties = DialogProperties( - usePlatformDefaultWidth = false, // ✅ 가로 전체 - dismissOnBackPress = false, // 우리가 직접 처리 - dismissOnClickOutside = false // 우리가 스크림에서 처리 - ) - ) { - - BackHandler(enabled = true) { animateAndDismiss() } - - Box(Modifier.fillMaxSize()) { - // 스크림 (배경) - Box( - Modifier - .matchParentSize() - .background(SpotTheme.colors.black.copy(alpha = 0.4f)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - blurFocusRequester.requestFocus() - keyboard?.hide() - animateAndDismiss() - } - ) - - // 시트 본체 (위 레이어) - Box( - Modifier - .fillMaxWidth() - .height(sheetHeight) - .offset { IntOffset(0, sheetOffset.value.roundToInt()) } - .clip(SpotShapes.SoftTop) - .background(SpotTheme.colors.white) - .imePadding() // 키보드 올라와도 시트 고정, 내부만 패딩 - .focusRequester(blurFocusRequester) - .focusable() - // ⬇︎ 시트 빈 공간 탭 -> 포커스 이동 + 키보드 닫기 (dismiss는 안 함) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - blurFocusRequester.requestFocus() - keyboard?.hide() - } - ) { - // === 네 기존 UI 그대로 === - Column( - Modifier - .fillMaxSize() - .padding(16.dp) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - blurFocusRequester.requestFocus() - keyboard?.hide() - }, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(Modifier.width(24.dp)) - Text( - text = "스터디 지역", - style = SpotTheme.typography.small_400.copy(fontSize = 18.sp), - color = SpotTheme.colors.black - ) - IconButton(onClick = { animateAndDismiss() }) { - Icon( - painter = painterResource(com.umcspot.spot.designsystem.R.drawable.dismiss), - contentDescription = "닫기", - tint = SpotTheme.colors.black - ) - } - } - - Spacer(Modifier.height(6.dp)) - - Text( - text = "스터디를 진행하고 싶은 지역을 추가해주세요.", - style = SpotTheme.typography.h3.copy(fontSize = 15.sp), - color = SpotTheme.colors.gray500 - ) - Text( - text = "최대 10개까지 추가할 수 있어요", - style = SpotTheme.typography.h5.copy(fontSize = 13.sp), - color = SpotTheme.colors.gray500 - ) - - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - textStyle = SpotTheme.typography.h5.copy( - fontSize = 14.sp - ), - placeholder = { - Text( - text = "OO시, OO구, OO동", - style = SpotTheme.typography.h5.copy(fontSize = 14.sp), - color = SpotTheme.colors.gray400 - ) - }, - trailingIcon = { - IconButton( - onClick = { - // 포커스 있으면 키보드 닫기 - if (isFocused) { - blurFocusRequester.requestFocus() // ← 포커스를 빼앗아온다 - keyboard?.hide() - } else { - focusRequester.requestFocus() - keyboard?.show() - } - }) { - Icon( - painter = painterResource(com.umcspot.spot.designsystem.R.drawable.search), - contentDescription = "검색", - tint = SpotTheme.colors.gray500, - modifier = Modifier.size(15.dp) - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .onFocusChanged { fs -> - isFocused = fs.isFocused - if (isFocused) keyboard?.show() - } - ) - - SelectedChips( - items = selected, - onRemove = onRemoveSelected - ) - - if (results.isNotEmpty()) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = navBarPadding) - .background(SpotTheme.colors.white), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(results, key = { it.code }) { row -> - ListItem( - headlineContent = { - Text( - text = row.name, - fontSize = 18.sp, - maxLines = 1 - ) - }, - colors = ListItemDefaults.colors( - containerColor = SpotTheme.colors.white - ), - modifier = Modifier - .fillMaxWidth() - .clickable { - onAddSelected(row.name) - } - ) - HorizontalDivider() - } - } - } else { - Text( - text = "검색 결과가 없습니다.", - color = SpotTheme.colors.gray400, - style = SpotTheme.typography.small_300, - modifier = Modifier.padding(top = 14.dp), - ) - } - } - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun SelectedChips( - items: List, - onRemove: (String) -> Unit -) { - if (items.isEmpty()) return - - val scrollState = rememberScrollState() - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .horizontalScroll(scrollState), // 👈 가로 스크롤 추가 - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - items.forEach { name -> - AssistChip( - onClick = { /* no-op */ }, - label = { Text(name, style = SpotTheme.typography.small_300.copy(fontSize = 14.sp)) }, - trailingIcon = { - Icon( - painter = painterResource(R.drawable.dismiss), - contentDescription = "삭제", - tint = SpotTheme.colors.B500, - modifier = Modifier - .size(14.dp) - .clickable { onRemove(name) } - - ) - }, - colors = AssistChipDefaults.assistChipColors( - containerColor = SpotTheme.colors.B100, - labelColor = SpotTheme.colors.B500 - ), - border = BorderStroke(1.dp, SolidColor(SpotTheme.colors.B100)), - shape = RoundedCornerShape(percent = 50) // 👈 타원(캡슐) 모양 - ) - } - } -} - diff --git a/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationStudyScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationStudyScreen.kt index 05d337cd..22c49002 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationStudyScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationStudyScreen.kt @@ -1,6 +1,5 @@ package com.umcspot.spot.study.preferLocation -import PreferLocationBottomSheet import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -29,6 +28,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.bottomsheet.LocationBottomSheet import com.umcspot.spot.designsystem.component.empty.EmptyAlertWithButton import com.umcspot.spot.designsystem.component.study.StudyListItem import com.umcspot.spot.designsystem.shapes.SpotShapes @@ -152,8 +152,7 @@ fun PreferLocationStudyScreen( } // 지역 선택 바텀시트 - PreferLocationBottomSheet( - contentPadding = contentPadding, + LocationBottomSheet( visible = showSheet, query = query, onQueryChange = { viewmodel.searchLocation(it) }, diff --git a/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterScreen.kt index d36a9d75..a85abf99 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterScreen.kt @@ -36,18 +36,19 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.umcspot.spot.designsystem.R -import com.umcspot.spot.designsystem.component.button.MultiButton import com.umcspot.spot.designsystem.component.button.TextButton import com.umcspot.spot.designsystem.component.button.TextToggleButton +import com.umcspot.spot.designsystem.component.study.section.ActivityThemeSection +import com.umcspot.spot.designsystem.component.study.section.ActivityTypeSection import com.umcspot.spot.designsystem.theme.SpotTheme import com.umcspot.spot.model.ActivityType import com.umcspot.spot.model.FeeRange import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.ui.extension.screenHeightDp @Composable fun RecruitingStudyFilterScreen( - contentPadding : PaddingValues, + contentPadding: PaddingValues, onAcceptFilterClick: () -> Unit, vm: RecruitingStudyFilterViewModel = hiltViewModel(), ) { @@ -89,7 +90,7 @@ fun RecruitingStudyFilterScreenContent( activityType: ActivityType?, // ✅ 단일 값 (nullable) fee: FeeRange?, // ✅ 단일 값 (nullable) theme: StudyTheme?, // ✅ 단일 값 (nullable) - buttonEnabled : Boolean, + buttonEnabled: Boolean, onSetActivity: (ActivityType) -> Unit, // ✅ set* 로직 (같은 값 다시 누르면 해제는 VM이 처리) onSetFee: (FeeRange?) -> Unit, onSetTheme: (StudyTheme) -> Unit, @@ -108,6 +109,14 @@ fun RecruitingStudyFilterScreenContent( .verticalScroll(rememberScrollState()) // ✅ 스크롤 .padding(horizontal = 16.dp) ) { + Text( + text = "활동", + style = SpotTheme.typography.medium_500.copy(fontSize = 15.sp), + color = SpotTheme.colors.black + ) + + Spacer(modifier = Modifier.height(10.dp)) + ActivityTypeSection( activityType = activityType, onSelect = onSetActivity @@ -123,6 +132,15 @@ fun RecruitingStudyFilterScreenContent( Spacer(modifier = Modifier.height(30.dp)) + Text( + text = "스터디 테마", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.black + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + ActivityThemeSection( activityTheme = theme, onSelect = onSetTheme @@ -155,42 +173,6 @@ fun RecruitingStudyFilterScreenContent( } } -@Composable -fun ActivityTypeSection( - activityType: ActivityType?, - onSelect: (ActivityType) -> Unit -) { - Column( - modifier = Modifier - .wrapContentSize() - ) { - Text( - text = "활동", - style = SpotTheme.typography.medium_500.copy(fontSize = 15.sp), - color = SpotTheme.colors.black - ) - - Spacer(modifier = Modifier.height(10.dp)) - - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(ActivityType.entries) { type -> - val iconRes = when (type) { - ActivityType.ONLINE -> painterResource( R.drawable.online) - ActivityType.OFFLINE -> painterResource(R.drawable.offline) - } - - MultiButton( - text = type.label, - painter = iconRes, - width = 140.dp, - checked = activityType == type, - onClick = { onSelect(type) }, - ) - } - } - } -} - @Composable fun ActivityFeeSection( activityFee: FeeRange?, @@ -211,7 +193,7 @@ fun ActivityFeeSection( FlowRow( horizontalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp) - ){ + ) { FeeRange.entries.forEach { fee -> TextToggleButton( text = fee.label, @@ -224,51 +206,6 @@ fun ActivityFeeSection( } } -@Composable -fun ActivityThemeSection( - activityTheme: StudyTheme?, - onSelect : (StudyTheme) -> Unit -) { - Column( - modifier = Modifier - .fillMaxWidth() - ) { - Text( - text = "스터디 테마", - style = SpotTheme.typography.medium_500.copy(fontSize = 15.sp), - color = SpotTheme.colors.black - ) - Spacer(modifier = Modifier.height(10.dp)) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ){ - StudyTheme.entries.forEach { theme -> - val iconRes = when (theme) { - StudyTheme.LANGUAGE -> painterResource(R.drawable.language) - StudyTheme.LICENSE -> painterResource(R.drawable.license) - StudyTheme.EMPLOYMENT -> painterResource(R.drawable.employment) - StudyTheme.DISCUSSION -> painterResource(R.drawable.discussion) - StudyTheme.NEWS -> painterResource(R.drawable.news) - StudyTheme.SELFSTUDY -> painterResource(R.drawable.self_study) - StudyTheme.PROJECT -> painterResource(R.drawable.project) - StudyTheme.CONTEST -> painterResource(R.drawable.contest) - StudyTheme.MAJOR -> painterResource(R.drawable.major) - StudyTheme.ETC -> painterResource(R.drawable.resource_else) - } - - MultiButton( - text = theme.title, - painter = iconRes, - checked = activityTheme == theme, - width = 156.dp, - onClick = { onSelect(theme) }, - ) - } - } - } -} @Composable fun ResetFilterText( diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt index ee072a4e..58f00ae0 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt @@ -13,65 +13,128 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.umcspot.spot.designsystem.component.appBar.BackTopBar import com.umcspot.spot.designsystem.component.button.SpotActivationButton import com.umcspot.spot.designsystem.theme.SpotTheme import com.umcspot.spot.study.register.component.StepProgressBar +import com.umcspot.spot.study.register.model.RegisterStudySideEffect +import com.umcspot.spot.study.register.model.RegisterStudyState import com.umcspot.spot.study.register.screen.StudyCategoryScreen import com.umcspot.spot.study.register.screen.StudyInfoScreen import com.umcspot.spot.study.register.screen.StudyIntroduceScreen import com.umcspot.spot.study.register.screen.StudyPlaceScreen import com.umcspot.spot.ui.extension.screenHeightDp import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @Composable fun RegisterStudyRoute( contentPadding: PaddingValues, onBackClick: () -> Unit, -// navigateToNext: () -> Unit, navigateToHome: () -> Unit, - modifier: Modifier = Modifier - + modifier: Modifier = Modifier, + viewModel: RegisterStudyViewModel = hiltViewModel() ) { val pagerState = rememberPagerState(pageCount = { 4 }) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() - RegisterStudyScreen( - pagerState = pagerState, - contentPadding = contentPadding, - onBackClick = onBackClick, - isStepValid = { true }, - onSubmit = { navigateToHome() }, - modifier = modifier - ) + val handleBackPress: () -> Unit = { + if (pagerState.currentPage > 0) { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + } else { + onBackClick() + } + } + + BackHandler { handleBackPress() } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collectLatest { effect -> + when (effect) { + is RegisterStudySideEffect.NavigateToHome -> navigateToHome() + else -> {} + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + ) { + Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) + + BackTopBar( + title = "스터디 만들기", + onBackClick = handleBackPress, + modifier = Modifier.fillMaxWidth() + ) + + RegisterStudyScreen( + pagerState = pagerState, + uiState = uiState, + contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), + isStepValid = viewModel::isStepValid, + onSubmit = viewModel::submit, + modifier = modifier, + onStudyNameChange = viewModel::onStudyNameChange, + onThemeSelect = { theme -> + val currentThemes = uiState.studyThemes.toMutableList() + if (currentThemes.contains(theme)) currentThemes.remove(theme) + else if (currentThemes.size < 3) currentThemes.add(theme) + viewModel.onCategorySelect(currentThemes) + }, + onActivityTypeSelect = viewModel::onActivityTypeSelect, + onQueryChange = viewModel::onLocationQueryChange, + onSheetOpen = viewModel::openLocationSheet, + onSheetDismiss = viewModel::dismissLocationSheet, + onAddSelected = viewModel::addSelectedRegion, + onRemoveSelected = viewModel::removeSelectedRegion, + onMemberCountChange = viewModel::onMemberCountChange, + onFeeInfoChange = viewModel::onFeeInfoChange, + onPersonalityChange = viewModel::onPersonalityChange, + onDescriptionChange = viewModel::onDescriptionChange + ) + } } @Composable private fun RegisterStudyScreen( pagerState: PagerState, + uiState: RegisterStudyState, contentPadding: PaddingValues, - onBackClick: () -> Unit, isStepValid: (Int) -> Boolean, onSubmit: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onStudyNameChange: (String) -> Unit, + onThemeSelect: (com.umcspot.spot.model.StudyTheme) -> Unit, + onActivityTypeSelect: (com.umcspot.spot.model.ActivityType) -> Unit, + onQueryChange: (String) -> Unit, + onSheetOpen: () -> Unit, + onSheetDismiss: () -> Unit, + onAddSelected: (String) -> Unit, + onRemoveSelected: (String) -> Unit, + onMemberCountChange: (Int) -> Unit, + onFeeInfoChange: (Boolean?, String) -> Unit, + onPersonalityChange: (Int, Int) -> Unit, + onDescriptionChange: (String) -> Unit ) { val coroutineScope = rememberCoroutineScope() - if (pagerState.currentPage > 0) { - BackHandler { - coroutineScope.launch { - pagerState.animateScrollToPage(pagerState.currentPage - 1) - } - } - } else { - - BackHandler { - onBackClick() - } - } Column( modifier = modifier .fillMaxSize() @@ -93,19 +156,56 @@ private fun RegisterStudyScreen( userScrollEnabled = false ) { page -> when (page) { - 0 -> StudyCategoryScreen(onCategorySelect = { }) - 1 -> StudyPlaceScreen(onPlaceSelect = { }) - 2 -> StudyInfoScreen(onInfoValid = { }) - 3 -> StudyIntroduceScreen(onIntroduceValid = { }) + 0 -> StudyCategoryScreen( + studyName = uiState.studyName, + selectedThemes = uiState.studyThemes.toImmutableList(), + onStudyNameChange = onStudyNameChange, + onThemeSelect = onThemeSelect + ) + 1 -> StudyPlaceScreen( + activityType = uiState.activityType, + isSheetVisible = uiState.isSheetVisible, + query = uiState.locationQuery, + searchResults = uiState.locationResults, + selectedRegions = uiState.selectedRegions.toImmutableList(), + onActivityTypeSelect = onActivityTypeSelect, + onQueryChange = onQueryChange, + onSheetOpen = onSheetOpen, + onSheetDismiss = onSheetDismiss, + onAddSelected = onAddSelected, + onRemoveSelected = onRemoveSelected + ) + 2 -> StudyInfoScreen( + memberCount = uiState.memberCount, + onMemberCountChange = onMemberCountChange, + hasFee = uiState.hasFee, + feeAmount = uiState.feeAmount, + onFeeInfoChange = onFeeInfoChange, + preferences = persistentListOf( + uiState.networkingPreference, + uiState.goalDurationPreference, + uiState.discussionPreference, + uiState.learningPreference, + uiState.flexibilityPreference + ), + onPersonalityChange = onPersonalityChange + ) + 3 -> StudyIntroduceScreen( + description = uiState.description, + onDescriptionChange = onDescriptionChange, + onIntroduceValid = { } + ) } } Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + val isValid = isStepValid(pagerState.currentPage) + SpotActivationButton( modifier = Modifier.fillMaxWidth(), buttonText = if (pagerState.currentPage == 3) "스터디 만들기" else "다음", - isEnabled = isStepValid(pagerState.currentPage), + isEnabled = isValid, onClick = { coroutineScope.launch { if (pagerState.currentPage < 3) { @@ -120,4 +220,4 @@ private fun RegisterStudyScreen( Spacer(modifier = Modifier.height(screenHeightDp(13.dp))) } } -} +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt index b5afeab2..3ca2a74e 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt @@ -1,4 +1,178 @@ package com.umcspot.spot.study.register -class RegisterStudyViewModel { +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.common.location.LocationRow +import com.umcspot.spot.common.location.LocationStore +import com.umcspot.spot.common.location.searchLocations +import com.umcspot.spot.model.ActivityType +import com.umcspot.spot.study.register.model.RegisterStudySideEffect +import com.umcspot.spot.study.register.model.RegisterStudyState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RegisterStudyViewModel @Inject constructor( + @ApplicationContext private val appContext: Context +) : ViewModel() { + + private val _uiState = MutableStateFlow(RegisterStudyState()) + val uiState = _uiState.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() + + private var allLocations: List = emptyList() + + private var searchJob: Job? = null + + init { + loadLocationData() + } + + fun onCategorySelect(themes: List) { + _uiState.update { it.copy(studyThemes = themes) } + } + + fun onStudyNameChange(name: String) { + _uiState.update { it.copy(studyName = name) } + } + + fun onActivityTypeSelect(type: ActivityType) { + _uiState.update { state -> + if (type == ActivityType.OFFLINE) { + state.copy( + activityType = type, + isSheetVisible = true + ) + } else { + state.copy( + activityType = type, + selectedRegions = emptyList(), + isSheetVisible = false + ) + } + } + } + + fun onLocationQueryChange(query: String) { + _uiState.update { it.copy(locationQuery = query) } + searchJob?.cancel() + searchJob = viewModelScope.launch(Dispatchers.IO) { + delay(300L) + searchLocation(query) + } + } + + fun addSelectedRegion(region: String) { + if (_uiState.value.selectedRegions.size < 10 && !_uiState.value.selectedRegions.contains(region)) { + _uiState.update { + it.copy( + selectedRegions = it.selectedRegions + region + ) + } + } + } + + fun removeSelectedRegion(region: String) { + _uiState.update { currentState -> + val updatedRegions = currentState.selectedRegions.toMutableList().apply { + remove(region) + } + currentState.copy(selectedRegions = updatedRegions) + } + } + + fun dismissLocationSheet() { + _uiState.update { it.copy(isSheetVisible = false) } + } + + fun openLocationSheet() { + _uiState.update { it.copy(isSheetVisible = true) } + } + + private fun loadLocationData() { + viewModelScope.launch(Dispatchers.IO) { + allLocations = LocationStore.load(appContext) + } + } + + private suspend fun searchLocation(query: String) { + if (query.isBlank()) { + _uiState.update { it.copy(locationResults = emptyList()) } + return + } + + if (allLocations.isEmpty()) { + allLocations = LocationStore.load(appContext) + } + + val filtered = searchLocations(query, allLocations) + _uiState.update { it.copy(locationResults = filtered) } + } + + fun onMemberCountChange(count: Int) { + _uiState.update { it.copy(memberCount = count) } + } + + fun onFeeInfoChange(hasFee: Boolean?, amount: String) { + _uiState.update { it.copy(hasFee = hasFee, feeAmount = amount) } + } + + fun onPersonalityChange(categoryIndex: Int, value: Int) { + _uiState.update { + when (categoryIndex) { + 0 -> it.copy(networkingPreference = value) + 1 -> it.copy(goalDurationPreference = value) + 2 -> it.copy(discussionPreference = value) + 3 -> it.copy(learningPreference = value) + 4 -> it.copy(flexibilityPreference = value) + else -> it + } + } + } + + fun onDescriptionChange(desc: String) { + _uiState.update { it.copy(description = desc) } + } + + fun isStepValid(step: Int): Boolean { + val state = _uiState.value + return when (step) { + 0 -> state.studyName.isNotBlank() && state.studyThemes.isNotEmpty() + 1 -> { + if (state.activityType == null) return false + if (state.activityType == ActivityType.OFFLINE) state.selectedRegions.isNotEmpty() else true + } + 2 -> { + val isFeeValid = state.hasFee != null && (!state.hasFee || state.feeAmount.isNotBlank()) + val isPersonalityValid = state.networkingPreference != null && + state.goalDurationPreference != null && + state.discussionPreference != null && + state.learningPreference != null && + state.flexibilityPreference != null + + state.memberCount > 1 && isFeeValid && isPersonalityValid + } + 3 -> state.description.isNotBlank() + else -> false + } + } + + fun submit() { + viewModelScope.launch { + _sideEffect.emit(RegisterStudySideEffect.NavigateToHome) + } + } } \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/BinaryChoiceRow.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/BinaryChoiceRow.kt new file mode 100644 index 00000000..1cbac74e --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/BinaryChoiceRow.kt @@ -0,0 +1,55 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun BinaryChoiceRow( + leftText: String, + rightText: String, + selectedIndex: Int?, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + SelectionChip( + text = leftText, + isSelected = selectedIndex == 0, + onClick = { onSelect(0) }, + modifier = Modifier.weight(1f) + ) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(screenWidthDp(6.5.dp))) + VerticalDivider( + modifier = Modifier.height(screenHeightDp(12.dp)), + thickness = 1.dp, + color = SpotTheme.colors.gray300 + ) + Spacer(modifier = Modifier.width(screenWidthDp(6.5.dp))) + } + + SelectionChip( + text = rightText, + isSelected = selectedIndex == 1, + onClick = { onSelect(1) }, + modifier = Modifier.weight(1f) + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/FeeInputSection.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/FeeInputSection.kt new file mode 100644 index 00000000..7c73282f --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/FeeInputSection.kt @@ -0,0 +1,49 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun FeeInputSection( + hasFee: Boolean?, + feeAmount: String, + onFeeTypeChange: (Boolean) -> Unit, + onFeeAmountChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + if (hasFee == true) screenWidthDp(12.dp) else screenWidthDp(14.dp) + ), + verticalAlignment = Alignment.CenterVertically + ) { + SelectionChip( + text = "없음", + isSelected = hasFee == false, + onClick = { onFeeTypeChange(false) }, + modifier = Modifier.weight(1f) + ) + + SelectionChip( + text = "있음", + isSelected = hasFee == true, + onClick = { onFeeTypeChange(true) }, + modifier = Modifier.weight(1f) + ) + + if (hasFee == true) { + PriceTextField( + value = feeAmount, + onValueChange = onFeeAmountChange, + modifier = Modifier.weight(1f) + ) + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/MemberCountSelector.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/MemberCountSelector.kt new file mode 100644 index 00000000..6ca17dd1 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/MemberCountSelector.kt @@ -0,0 +1,157 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.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.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun MemberCountSelector( + memberCount: Int, + onMemberCountChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + val memberOptions = remember { persistentListOf(2, 3, 4, 5) } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = screenHeightDp(4.5.dp)), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier.height(screenHeightDp(30.dp)), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = "총 인원 (팀장 포함)", + style = SpotTheme.typography.h5 + ) + } + + Row(verticalAlignment = Alignment.Top) { + Column( + modifier = Modifier.width(screenWidthDp(71.dp)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(screenHeightDp(30.dp)) + .border( + width = 1.dp, + color = SpotTheme.colors.gray200, + shape = RoundedCornerShape(6.dp) + ) + .clip(RoundedCornerShape(6.dp)) + .noRippleClickable { expanded = !expanded } + .padding(horizontal = screenWidthDp(10.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = memberCount.toString(), + style = SpotTheme.typography.regular_500, + ) + + Icon( + painter = painterResource( + id = if (expanded) R.drawable.arrow_up else R.drawable.arrow_down + ), + contentDescription = null, + tint = SpotTheme.colors.B500, + modifier = Modifier.size(screenWidthDp(14.dp)) + ) + } + + if (expanded) { + Spacer(modifier = Modifier.height(screenHeightDp(4.dp))) + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = SpotTheme.colors.white, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = SpotTheme.colors.gray200, + shape = RoundedCornerShape(6.dp) + ) + .clip(RoundedCornerShape(6.dp)) + ) { + memberOptions.forEachIndexed { index, selectionOption -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(screenHeightDp(30.dp)) + .noRippleClickable { + onMemberCountChange(selectionOption) + expanded = false + }, + contentAlignment = Alignment.Center + ) { + Text( + text = selectionOption.toString(), + style = SpotTheme.typography.regular_500, + textAlign = TextAlign.Center + ) + } + + if (index < memberOptions.lastIndex) { + HorizontalDivider( + color = SpotTheme.colors.gray300, + thickness = 0.5.dp, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.width(screenWidthDp(7.dp))) + + Box( + modifier = Modifier.height(screenHeightDp(30.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = "명", + style = SpotTheme.typography.regular_500 + ) + } + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/PriceTextField.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/PriceTextField.kt new file mode 100644 index 00000000..8806569c --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/PriceTextField.kt @@ -0,0 +1,79 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun PriceTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.height(screenHeightDp(35.dp)), + textStyle = SpotTheme.typography.medium_500.copy( + color = SpotTheme.colors.black, + textAlign = TextAlign.Start + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .background( + color = SpotTheme.colors.white, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = SpotTheme.colors.B500, + shape = RoundedCornerShape(6.dp) + ) + .padding(end = screenWidthDp(7.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(start = screenWidthDp(10.dp)) + ) { + innerTextField() + } + + Spacer(modifier = Modifier.width(screenWidthDp(4.dp))) + + Text( + text = "원", + style = SpotTheme.typography.medium_500, + color = SpotTheme.colors.black + ) + } + } + ) +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectedRegionsSection.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectedRegionsSection.kt new file mode 100644 index 00000000..1f1cd21c --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectedRegionsSection.kt @@ -0,0 +1,129 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.Black +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SelectedRegionsSection( + selectedRegions: ImmutableList, + onRemoveClick: (String) -> Unit, + onAddClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(screenHeightDp(10.dp)) + ) { + selectedRegions.forEach { region -> + RegionItem( + regionName = region, + onRemoveClick = { onRemoveClick(region) } + ) + } + + if (selectedRegions.size < 3) { + AddRegionButton(onClick = onAddClick) + } + } +} + +@Composable +private fun RegionItem( + regionName: String, + onRemoveClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = SpotTheme.colors.B500, + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = screenHeightDp(10.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_location), + contentDescription = "지역", + tint = SpotTheme.colors.B500, + modifier = Modifier.size(screenWidthDp(18.dp)) + ) + Spacer(modifier = Modifier.size(screenWidthDp(8.dp))) + Text( + text = regionName, + style = SpotTheme.typography.h5, + color = SpotTheme.colors.B500 + ) + } + IconButton(onClick = onRemoveClick) { + Icon( + painter = painterResource(id = R.drawable.dismiss), + contentDescription = "삭제", + tint = SpotTheme.colors.B500, + modifier = Modifier.size(screenWidthDp(18.dp)) + ) + } + } +} + +@Composable +private fun AddRegionButton( + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = screenWidthDp(5.dp), + vertical = screenHeightDp(9.dp) + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.multiple), + contentDescription = "추가", + tint = SpotTheme.colors.Black, + modifier = Modifier.size(screenWidthDp(14.dp)) + ) + Spacer(modifier = Modifier.size(screenWidthDp(4.dp))) + Text( + text = "지역 추가", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.Black + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectionChip.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectionChip.kt new file mode 100644 index 00000000..ac059b82 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectionChip.kt @@ -0,0 +1,54 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.B100 +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp + +@Composable +fun SelectionChip( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .height(screenHeightDp(35.dp)) + .background( + color = if (isSelected) SpotTheme.colors.B100 else SpotTheme.colors.white, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = SpotTheme.colors.gray200, + shape = RoundedCornerShape(6.dp) + ) + .clip(RoundedCornerShape(6.dp)) + .noRippleClickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = SpotTheme.typography.medium_500, + color = if (isSelected) SpotTheme.colors.B500 else SpotTheme.colors.black, + modifier = Modifier.padding(vertical = screenHeightDp(7.dp)), + textAlign = TextAlign.Center, + maxLines = 1 + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt index 3070c02a..8ddfd3bd 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt @@ -1,4 +1,35 @@ package com.umcspot.spot.study.register.model -class RegisterStudyState { +import com.umcspot.spot.common.location.LocationRow +import com.umcspot.spot.model.ActivityType +import com.umcspot.spot.model.StudyTheme + +data class RegisterStudyState( + + val studyName: String = "", + val studyThemes: List = emptyList(), + + val activityType: ActivityType? = null, + val isSheetVisible: Boolean = false, + val locationQuery: String = "", + val locationResults: List = emptyList(), + val selectedRegions: List = emptyList(), + + val memberCount: Int = 2, + val hasFee: Boolean? = null, + val feeAmount: String = "", + + val networkingPreference: Int? = null, + val goalDurationPreference: Int? = null, + val discussionPreference: Int? = null, + val learningPreference: Int? = null, + val flexibilityPreference: Int? = null, + + val description: String = "", + val studyImageUri: String? = null +) + +sealed interface RegisterStudySideEffect { + data object NavigateToHome : RegisterStudySideEffect + data class ShowSnackBar(val message: String) : RegisterStudySideEffect } \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyCategoryScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyCategoryScreen.kt index 9dd9624a..8b01874b 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyCategoryScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyCategoryScreen.kt @@ -7,37 +7,62 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.unit.dp +import com.umcspot.spot.designsystem.component.study.section.ActivityThemeSection +import com.umcspot.spot.designsystem.theme.G400 import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.study.register.component.StudyNameTextField +import com.umcspot.spot.ui.extension.screenHeightDp +import kotlinx.collections.immutable.ImmutableList @Composable fun StudyCategoryScreen( - onCategorySelect: (String) -> Unit, + studyName: String, + selectedThemes: ImmutableList, + onStudyNameChange: (String) -> Unit, + onThemeSelect: (StudyTheme) -> Unit, modifier: Modifier = Modifier ) { - var studyName by remember { mutableStateOf("") } - Column( modifier = modifier .fillMaxSize() - .padding(top = 68.dp) + .padding(top = screenHeightDp(65.dp)) ) { Text( text = "어떤 스터디인가요?", - style = SpotTheme.typography.h1 + style = SpotTheme.typography.h3 ) - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) StudyNameTextField( value = studyName, - onValueChange = { studyName = it } + onValueChange = onStudyNameChange + ) + + Spacer(modifier = Modifier.height(screenHeightDp(40.dp))) + + Text( + text = "카테고리를 선택해주세요", + style = SpotTheme.typography.h3, + color = SpotTheme.colors.black + ) + + Spacer(modifier = Modifier.height(screenHeightDp(4.dp))) + + Text( + text = "최대 3개까지 선택할 수 있어요", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.G400 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + ActivityThemeSection( + selectedThemes = selectedThemes, + onSelect = onThemeSelect ) } } diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt index b423b79b..2e420cde 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt @@ -1,12 +1,104 @@ package com.umcspot.spot.study.register.screen import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +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.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.register.component.BinaryChoiceRow +import com.umcspot.spot.study.register.component.FeeInputSection +import com.umcspot.spot.study.register.component.MemberCountSelector +import com.umcspot.spot.ui.extension.screenHeightDp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Composable -fun StudyInfoScreen(modifier: Modifier = Modifier, onInfoValid: () -> Unit) { - Column(modifier.fillMaxSize()) { Text("3단계: 스터디 정보 입력 화면") } -} +fun StudyInfoScreen( + memberCount: Int, + onMemberCountChange: (Int) -> Unit, + hasFee: Boolean?, + feeAmount: String, + onFeeInfoChange: (Boolean?, String) -> Unit, + preferences: ImmutableList, + onPersonalityChange: (Int, Int) -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .padding(top = screenHeightDp(65.dp)) + .verticalScroll(scrollState) + ) { + Text( + text = "모집 정보를 작성해주세요.", + style = SpotTheme.typography.h3 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + MemberCountSelector( + memberCount = memberCount, + onMemberCountChange = onMemberCountChange + ) + + Spacer(modifier = Modifier.height(screenHeightDp(40.dp))) + + Text( + text = "활동비", + style = SpotTheme.typography.h5 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + FeeInputSection( + hasFee = hasFee, + feeAmount = feeAmount, + onFeeTypeChange = { type -> + onFeeInfoChange(type, if (type == false) "" else feeAmount) + }, + onFeeAmountChange = { amount -> + onFeeInfoChange(hasFee, amount) + } + ) + + Spacer(modifier = Modifier.height(screenHeightDp(40.dp))) + + Text( + text = "스터디의 성격을 표현하는 단어를 모두 선택해봐요.", + style = SpotTheme.typography.h5 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + val choiceLabels = persistentListOf( + "네트워킹 중시" to "목표/규율 중시", + "단기 목표" to "장기 목표", + "개인 학습 + 함께 토론형" to "공동 학습 + 동시 진행형", + "학습형" to "토론형", + "가볍게 + 유연하게" to "규칙적인 + 계획적인" + ) + + choiceLabels.forEachIndexed { index, (left, right) -> + BinaryChoiceRow( + leftText = left, + rightText = right, + selectedIndex = preferences[index], + onSelect = { value -> onPersonalityChange(index, value) } + ) + if (index < choiceLabels.lastIndex) { + Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) + } + } + + Spacer(modifier = Modifier.height(screenHeightDp(40.dp))) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt index 6fe4364a..e833e4db 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt @@ -1,12 +1,154 @@ package com.umcspot.spot.study.register.screen +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +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.foundation.text.BasicTextField +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.Default +import com.umcspot.spot.designsystem.theme.G100 +import com.umcspot.spot.designsystem.theme.G400 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp @Composable -fun StudyIntroduceScreen(modifier: Modifier = Modifier, onIntroduceValid: () -> Unit) { - Column(modifier.fillMaxSize()) { Text("4단계: 스터디 소개 입력 화면") } -} +fun StudyIntroduceScreen( + description: String, + onDescriptionChange: (String) -> Unit, + onIntroduceValid: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + var isImageSelected by remember { mutableStateOf(false) } + + LaunchedEffect(description) { + onIntroduceValid(description.isNotEmpty()) + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(top = screenHeightDp(65.dp)) + ) { + Text( + text = "마지막으로. 이 스터디에 대해\n자세히 소개할 것이 있다면 작성해주세요.", + style = SpotTheme.typography.h3 + ) + Spacer(modifier = Modifier.height(screenHeightDp(4.dp))) + + Text( + text = "자세히 적을수록, 모집 확률은 올라가요.", + color = SpotTheme.colors.G400, + style = SpotTheme.typography.regular_500 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + BasicTextField( + value = description, + onValueChange = onDescriptionChange, + modifier = Modifier.fillMaxWidth(), + textStyle = SpotTheme.typography.medium_500.copy( + color = SpotTheme.colors.black + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = screenHeightDp(134.dp)) + .border( + width = 1.dp, + color = if (description.isEmpty()) SpotTheme.colors.Default else SpotTheme.colors.B500, + shape = RoundedCornerShape(6.dp) + ) + .padding( + horizontal = screenWidthDp(10.dp), + vertical = screenHeightDp(7.dp) + ) + ) { + if (description.isEmpty()) { + Text( + text = "이 스터디의 목표, 선호하는 스터디원, 앞으로의 진행 방식 등 ", + style = SpotTheme.typography.medium_500, + color = SpotTheme.colors.Default + ) + } + innerTextField() + } + } + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "스터디 대표 이미지", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.black + ) + Spacer(modifier = Modifier.width(screenWidthDp(4.dp))) + Text( + text = "(선택)", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.G400 + ) + } + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + Box( + modifier = Modifier + .size(width = screenWidthDp(80.dp), height = screenHeightDp(80.dp)) + .clip(RoundedCornerShape(6.dp)) + .background(if (isImageSelected) SpotTheme.colors.black else SpotTheme.colors.G100) // 이미지가 없을 때 배경색 지정 (G100) + .noRippleClickable { + isImageSelected = !isImageSelected + }, + contentAlignment = Alignment.Center + ) { + if (isImageSelected) { + Image( + painter = painterResource(id = R.drawable.image), + contentDescription = "Selected Study Image", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + painter = painterResource(id = R.drawable.image), + contentDescription = "Upload Image", + tint = SpotTheme.colors.G400, + modifier = Modifier.size(screenWidthDp(24.dp)) + ) + } + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyPlaceScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyPlaceScreen.kt index c4942158..8b073066 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyPlaceScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyPlaceScreen.kt @@ -1,12 +1,76 @@ package com.umcspot.spot.study.register.screen +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.common.location.LocationRow +import com.umcspot.spot.designsystem.component.bottomsheet.LocationBottomSheet +import com.umcspot.spot.designsystem.component.study.section.ActivityTypeSection +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.model.ActivityType +import com.umcspot.spot.study.register.component.SelectedRegionsSection +import com.umcspot.spot.ui.extension.screenHeightDp +import kotlinx.collections.immutable.ImmutableList @Composable -fun StudyPlaceScreen(modifier: Modifier = Modifier, onPlaceSelect: () -> Unit) { - Column(modifier.fillMaxSize()) { Text("2단계: 스터디 장소 선택 화면") } -} \ No newline at end of file +fun StudyPlaceScreen( + activityType: ActivityType?, + isSheetVisible: Boolean, + query: String, + searchResults: List, + selectedRegions: ImmutableList, + onActivityTypeSelect: (ActivityType) -> Unit, + onQueryChange: (String) -> Unit, + onSheetOpen: () -> Unit, + onSheetDismiss: () -> Unit, + onAddSelected: (String) -> Unit, + onRemoveSelected: (String) -> Unit, + modifier: Modifier = Modifier +) { + LocationBottomSheet( + visible = isSheetVisible, + query = query, + results = searchResults, + onQueryChange = onQueryChange, + onDismiss = onSheetDismiss, + selected = selectedRegions, + onAddSelected = onAddSelected, + onRemoveSelected = onRemoveSelected + ) + + Column( + modifier = modifier + .fillMaxSize() + .padding(top = screenHeightDp(65.dp)) + ) { + Text( + text = "스터디는 어디서 진행하나요?", + style = SpotTheme.typography.h3 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + ActivityTypeSection( + activityType = activityType, + onSelect = onActivityTypeSelect + ) + + AnimatedVisibility(visible = activityType == ActivityType.OFFLINE && selectedRegions.isNotEmpty()) { + Column { + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + SelectedRegionsSection( + selectedRegions = selectedRegions, + onRemoveClick = onRemoveSelected, + onAddClick = onSheetOpen + ) + } + } + } +}