diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Calender.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Calender.kt deleted file mode 100644 index 45ffa80e..00000000 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Calender.kt +++ /dev/null @@ -1,265 +0,0 @@ -@file:OptIn(ExperimentalMaterial3Api::class) -package com.umcspot.spot.designsystem.component - -import android.annotation.SuppressLint -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -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.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.rememberTextMeasurer -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.min -import androidx.compose.ui.unit.sp -import com.kizitonwose.calendar.compose.HorizontalCalendar -import com.kizitonwose.calendar.compose.rememberCalendarState -import com.kizitonwose.calendar.core.CalendarDay -import com.kizitonwose.calendar.core.DayPosition -import com.umcspot.spot.designsystem.R -import com.umcspot.spot.designsystem.shapes.SpotShapes -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.G300 -import com.umcspot.spot.designsystem.theme.SpotTheme -import kotlinx.coroutines.launch -import java.time.DayOfWeek -import java.time.LocalDate -import java.time.Year -import java.time.YearMonth - -@Composable -fun SpotMonthCalendar( - modifier: Modifier = Modifier, - onDateSelected: (LocalDate) -> Unit = {}, - eventsByDate: Map = emptyMap(), - onMonthChanged: (YearMonth) -> Unit = {}, -) { - val nowMonth = YearMonth.now() - val firstDow = DayOfWeek.MONDAY - - val state = rememberCalendarState( - startMonth = nowMonth.minusMonths(12), - endMonth = nowMonth.plusMonths(12), - firstDayOfWeek = firstDow - ) - val scope = rememberCoroutineScope() - var selected by remember { mutableStateOf(null) } - - val visibleMonth by remember { - derivedStateOf { state.firstVisibleMonth.yearMonth } - } - - LaunchedEffect(visibleMonth) { - onMonthChanged(visibleMonth) - } - - val currentYm = visibleMonth - val canPrev = currentYm > state.startMonth - val canNext = currentYm < state.endMonth - - Column(modifier = modifier.fillMaxWidth().padding(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = { scope.launch { state.animateScrollToMonth(currentYm.minusMonths(1)) } }, - enabled = canPrev - ) { - Icon(painterResource(R.drawable.arrow_left), contentDescription = "이전 달", tint = B500) - } - - Text( - text = "${currentYm.year}년 ${currentYm.monthValue}월", - style = SpotTheme.typography.small_500, - fontSize = 26.sp, - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center - ) - - IconButton( - onClick = { scope.launch { state.animateScrollToMonth(currentYm.plusMonths(1)) } }, - enabled = canNext - ) { - Icon(painterResource(R.drawable.arrow_right), contentDescription = "다음 달", tint = B500) - } - } - - WeekdayRow() - - HorizontalCalendar( - state = state, - contentPadding = PaddingValues(horizontal = 2.dp), - dayContent = { day -> - DayCellStyled( - day = day, - isSelected = selected == day.date, - onClick = { - if (day.position == DayPosition.MonthDate) { - selected = day.date - onDateSelected(day.date) - } - }, - eventCount = eventsByDate[day.date] ?: 0 - ) - } - ) - } -} - - -@Composable -private fun WeekdayRow() { - val labels = listOf("월","화","수","목","금","토","일") - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 26.dp, bottom = 8.dp, start = 2.dp, end = 2.dp) - ) { - labels.forEachIndexed { idx, label -> - Text( - text = label, - style = SpotTheme.typography.small_500, - fontSize = 16.sp, - // ✅ 일요일 컬럼은 헤더도 B500 - color = if (idx == 6) SpotTheme.colors.B500 else SpotTheme.colors.black, - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - } - } -} - -@SuppressLint("UnusedBoxWithConstraintsScope") -@Composable -private fun DayCellStyled( - day: CalendarDay, - isSelected: Boolean, - onClick: () -> Unit, - eventCount: Int = 0, -) { - val isThisMonth = day.position == DayPosition.MonthDate - val isSunday = day.date.dayOfWeek == DayOfWeek.SUNDAY - - BoxWithConstraints( - modifier = Modifier - .aspectRatio(1f) - .padding(1.dp), - contentAlignment = Alignment.Center - ) { - val cellSide = min(maxWidth, maxHeight) - val bgSize = cellSide * 0.65f - - // 눌림 상태 - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val clickableModifier = Modifier.clickable( - enabled = isThisMonth, - interactionSource = interactionSource, - indication = null, - onClick = onClick - ) - - val dateTextStyle = SpotTheme.typography.small_500.copy(fontSize = 18.sp) - val measurer = rememberTextMeasurer() - val density = LocalDensity.current - val textHeightDp = remember(day.date.dayOfMonth) { - with(density) { - measurer - .measure(AnnotatedString(day.date.dayOfMonth.toString()), style = dateTextStyle) - .size.height.toDp() - } - } - - Box(modifier = clickableModifier.fillMaxSize()) { - - // 선택 배경(Hard) - 중앙 - if (isSelected) { - Box( - modifier = Modifier - .size(bgSize) - .align(Alignment.Center) - .clip(SpotShapes.Hard) - .background( - if (isPressed) SpotTheme.colors.B400.copy(alpha = 0.6f) - else SpotTheme.colors.B400.copy(alpha = 0.10f) - ) - ) - } - - Text( - text = day.date.dayOfMonth.toString(), - style = dateTextStyle, - color = when { - !isThisMonth -> SpotTheme.colors.G300 - isSunday -> SpotTheme.colors.B500 - else -> SpotTheme.colors.Black - }, - modifier = Modifier.align(Alignment.Center) - ) - - if (eventCount > 0 && isThisMonth) { - val gap = 4.dp - Box( - modifier = Modifier - .align(Alignment.Center) - .offset(y = textHeightDp / 2 + gap) - .size(6.dp) - .clip(CircleShape) - .background(SpotTheme.colors.B400) - ) - } - } - } -} - - - -@Preview(showBackground = true, widthDp = 500) -@Composable -private fun SpotMonthCalendarPreview_WithEvents() { - val y = Year.now().value // 현재 연도 기준 - val events = mapOf( - LocalDate.of(2024, 9, 5) to 1, // ✅ 9월 5일 - LocalDate.of(2024, 9, 12) to 2 // ✅ 9월 12일 (개수 예시로 2) - ) - - SpotMonthCalendar( - eventsByDate = events, - modifier = Modifier.padding(10.dp), - onMonthChanged = {} - ) -} diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/DateHeader.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/DateHeader.kt deleted file mode 100644 index 6b00fb9c..00000000 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/DateHeader.kt +++ /dev/null @@ -1,197 +0,0 @@ -@file:OptIn(ExperimentalMaterial3Api::class) -package com.umcspot.spot.designsystem.component - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.umcspot.spot.designsystem.shapes.SpotShapes -import com.umcspot.spot.designsystem.theme.Black -import com.umcspot.spot.designsystem.theme.G300 -import com.umcspot.spot.designsystem.theme.G400 -import com.umcspot.spot.designsystem.theme.SpotTheme -import com.umcspot.spot.designsystem.theme.White -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.Year -import java.time.YearMonth -import java.time.format.DateTimeFormatter - -// 포맷터 -private val dateFmt = DateTimeFormatter.ofPattern("yyyy.MM.dd.") -private val timeFmt = DateTimeFormatter.ofPattern("hh:mma") - -/** 한 줄 + 인라인 달력 토글 */ -@Composable -fun DateHeader( - label: String, - dateTime: LocalDateTime, - onDateChange: (LocalDateTime) -> Unit, - // 달력 커스터마이즈 전달 - eventsByDate: Map = emptyMap(), - onMonthChanged: (YearMonth) -> Unit = {}, - // 스타일 - containerColor: Color = White, - borderColor: Color = G300, - modifier: Modifier = Modifier -) { - var expanded by remember { mutableStateOf(false) } - - // 외곽 컨테이너 (스샷 느낌: 연한 보더 + 둥근 모서리) - Surface( - shape = SpotShapes.Hard, - color = containerColor, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke(1.dp, borderColor), - modifier = modifier.fillMaxWidth() - ) { - Column(Modifier.fillMaxWidth()) { - - // ── 상단: 라벨 + 날짜칩 + 시간칩 - CompactDateTimeRow( - label = label, - dateText = dateTime.format(dateFmt), - timeText = dateTime.format(timeFmt).lowercase(), - onClickDate = { expanded = !expanded }, // ⬅️ 날짜칩 누르면 달력 토글 - onClickTime = { /* TODO: TimePicker 연결 예정 */ }, - modifier = Modifier.fillMaxWidth() - ) - - // ── 펼쳐지는 달력 - AnimatedVisibility( - visible = expanded, - enter = expandVertically(), - exit = shrinkVertically() - ) { - // 달력 패딩 약간 - Column(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - SpotMonthCalendar( - eventsByDate = eventsByDate, - onMonthChanged = onMonthChanged, - onDateSelected = { picked -> - // 날짜만 변경, 시간은 유지 - val updated = dateTime.withYear(picked.year) - .withMonth(picked.monthValue) - .withDayOfMonth(picked.dayOfMonth) - onDateChange(updated) - expanded = false // 날짜 선택 후 닫기 (원하면 유지 가능) - } - ) - } - } - } - } -} - -@Composable -fun CompactDateTimeRow( - label: String, - dateText: String, - timeText: String, - onClickDate: () -> Unit, - onClickTime: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = SpotTheme.typography.small_500, - fontSize = 12.sp, - color = SpotTheme.colors.G400, - modifier = Modifier.weight(1f) - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - // 날짜 칩 - PillChip( - text = dateText, - onClick = onClickDate - ) - // 시간 칩 - PillChip( - text = timeText, - onClick = onClickTime - ) - } - } -} - -@Composable -private fun PillChip( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( - color = Color(0xFFE4EDFF), // 연한 파랑 배경 - contentColor = SpotTheme.colors.black, - shape = SpotShapes.Soft, - modifier = modifier - .height(24.dp) - .clickable(onClick = onClick) - ) { - Box( - modifier = Modifier.padding(horizontal = 10.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = text, - style = SpotTheme.typography.small_500, - fontSize = 12.sp - ) - } - } -} - - -/* ───────── Preview ───────── */ - -@Preview(showBackground = true, widthDp = 380) -@Composable -private fun InlineDateCalendarFieldPreview() { - val y = Year.now().value - var dt by remember { - mutableStateOf(LocalDateTime.of(y, 2, 1, 0, 0)) - } - val events = mapOf( - LocalDate.of(y, 2, 5) to 1, - LocalDate.of(y, 2, 12) to 2 - ) - - SpotTheme { - DateHeader( - label = "시작", - dateTime = dt, - onDateChange = { dt = it }, - eventsByDate = events, - modifier = Modifier.padding(10.dp) - ) - } -} diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/SpotPlannerCalendar.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/SpotPlannerCalendar.kt new file mode 100644 index 00000000..b6b88083 --- /dev/null +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/SpotPlannerCalendar.kt @@ -0,0 +1,170 @@ +package com.umcspot.spot.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.shape.CircleShape +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kizitonwose.calendar.compose.CalendarState +import com.kizitonwose.calendar.compose.HorizontalCalendar +import com.kizitonwose.calendar.core.DayPosition +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.Black +import com.umcspot.spot.designsystem.theme.G300 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.TextStyle +import java.util.Locale + +@Composable +fun SpotPlannerCalendar( + isExpanded: Boolean, + monthState: CalendarState, + selectedDate: LocalDate, + daysOfWeek: List, + onDateSelected: (LocalDate) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = screenHeightDp(4.dp)) + ) { + for (dayOfWeek in daysOfWeek) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + text = dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN), + style = SpotTheme.typography.regular_500, + color = if (dayOfWeek == DayOfWeek.SUNDAY) SpotTheme.colors.B500 else SpotTheme.colors.Black + ) + } + } + + if (isExpanded) { + HorizontalCalendar( + state = monthState, + userScrollEnabled = true, + dayContent = { day -> + DayCell( + date = day.date, + isCurrentMonth = day.position == DayPosition.MonthDate, + isSelected = selectedDate == day.date, + onClick = onDateSelected + ) + } + ) + } else { + CustomWeekRow( + selectedDate = selectedDate, + onDateSelected = onDateSelected + ) + } + } +} + +@Composable +fun CustomWeekRow( + selectedDate: LocalDate, + onDateSelected: (LocalDate) -> Unit +) { + val daysFromMonday = (selectedDate.dayOfWeek.value - 1).toLong() + val startOfWeek = selectedDate.minusDays(daysFromMonday) + + Row(modifier = Modifier.fillMaxWidth()) { + for (i in 0..6) { + val date = startOfWeek.plusDays(i.toLong()) + Box(modifier = Modifier.weight(1f)) { + DayCell( + date = date, + isCurrentMonth = true, + isSelected = selectedDate == date, + onClick = onDateSelected + ) + } + } + } +} + +@Composable +private fun DayCell( + date: LocalDate, + isCurrentMonth: Boolean, + isSelected: Boolean, + onClick: (LocalDate) -> Unit +) { + val isSunday = date.dayOfWeek == DayOfWeek.SUNDAY + val isToday = date == LocalDate.now() + + Column( + modifier = Modifier + .aspectRatio(1.3f) + .clickable( + enabled = isCurrentMonth, + onClick = { onClick(date) }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(29.dp) + .clip(SpotShapes.Hard) + .background(if (isSelected) SpotTheme.colors.B100 else Color.Transparent), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = date.dayOfMonth.toString(), + style = SpotTheme.typography.regular_500.copy(fontSize = 16.sp), + color = when { + !isCurrentMonth -> SpotTheme.colors.G300 + isSunday -> SpotTheme.colors.B500 + else -> SpotTheme.colors.Black + } + ) + + if (isToday && isCurrentMonth) { + Box( + modifier = Modifier + .padding(top = 2.dp) + .size(4.dp) + .background(SpotTheme.colors.B400, CircleShape) + ) + } else { + Spacer(modifier = Modifier.height(6.dp)) + } + } + } + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/appBar/AppBar.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/appBar/AppBar.kt index 610f2630..c9913fe0 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/appBar/AppBar.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/appBar/AppBar.kt @@ -31,7 +31,6 @@ 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 -import androidx.compose.ui.unit.sp import com.umcspot.spot.designsystem.R import com.umcspot.spot.designsystem.shapes.SpotShapes import com.umcspot.spot.designsystem.theme.G300 @@ -41,6 +40,7 @@ import com.umcspot.spot.designsystem.theme.White import com.umcspot.spot.ui.extension.screenHeightDp import com.umcspot.spot.ui.extension.screenWidthDp + @Composable fun AppBarHome ( hasAlert: Boolean = false, 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 0bf0c88d..32d73321 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 @@ -24,6 +24,7 @@ val Y400 = Color(0xFFFD8653) val R500 = Color(0xFFF34343) val B200 = Color(0xFFD3E1FD) val B100 = Color(0xFFEDF4FF) +val B50 = Color(0xFFF5F9FF) val Black = Color(0xFF1E1E1E) val G500 = Color(0xFF4F4F56) @@ -53,6 +54,7 @@ val SpotColors.B500: Color get() = primary val SpotColors.B400: Color get() = primaryStrong val SpotColors.B200: Color get() = primarySoft val SpotColors.B100: Color get() = primarySoftest +val SpotColors.B50: Color get() = primaryFaint val SpotColors.Y400: Color get() = secondary val SpotColors.R500: Color get() = error @@ -91,6 +93,7 @@ class SpotColors( primaryStrong: Color, primarySoft: Color, primarySoftest: Color, + primaryFaint: Color, secondary: Color, error: Color, gray500: Color, @@ -114,6 +117,7 @@ class SpotColors( var primaryStrong by mutableStateOf(primaryStrong); private set var primarySoft by mutableStateOf(primarySoft); private set var primarySoftest by mutableStateOf(primarySoftest); private set + var primaryFaint by mutableStateOf(primaryFaint); private set var secondary by mutableStateOf(secondary); private set var error by mutableStateOf(error); private set @@ -140,7 +144,7 @@ class SpotColors( var isLight by mutableStateOf(isLight) fun copy() = SpotColors( - primary, primaryStrong, primarySoft, primarySoftest, + primary, primaryStrong, primarySoft, primarySoftest,primaryFaint, secondary, error, gray500, gray400, gray300, gray200, gray100, default, black, white, @@ -154,6 +158,7 @@ class SpotColors( primaryStrong = colors.primaryStrong primarySoft = colors.primarySoft primarySoftest = colors.primarySoftest + primaryFaint = colors.primaryFaint secondary = colors.secondary error = colors.error @@ -186,6 +191,7 @@ fun SpotDayColors(): SpotColors = SpotColors( primaryStrong = B400, primarySoft = B200, primarySoftest = B100, + primaryFaint = B50, secondary = Y400, error = R500, diff --git a/core/designsystem/src/main/res/drawable/ic_fire.xml b/core/designsystem/src/main/res/drawable/ic_fire.xml new file mode 100644 index 00000000..ba735e57 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_fire.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_heart.xml b/core/designsystem/src/main/res/drawable/ic_heart.xml new file mode 100644 index 00000000..ead48066 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_hit_count.xml b/core/designsystem/src/main/res/drawable/ic_hit_count.xml new file mode 100644 index 00000000..b95054ef --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_hit_count.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_laugh.xml b/core/designsystem/src/main/res/drawable/ic_laugh.xml new file mode 100644 index 00000000..5e4a4393 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_laugh.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_leader.png b/core/designsystem/src/main/res/drawable/ic_leader.png new file mode 100644 index 00000000..1cd85432 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/ic_leader.png differ diff --git a/core/designsystem/src/main/res/drawable/ic_like_count.xml b/core/designsystem/src/main/res/drawable/ic_like_count.xml new file mode 100644 index 00000000..61dc838e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_like_count.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_meetball.xml b/core/designsystem/src/main/res/drawable/ic_meetball.xml new file mode 100644 index 00000000..f6c49872 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_meetball.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_member.xml b/core/designsystem/src/main/res/drawable/ic_member.xml new file mode 100644 index 00000000..c033f149 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_member.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_plus.xml b/core/designsystem/src/main/res/drawable/ic_plus.xml new file mode 100644 index 00000000..80e8bfe8 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_plus_study.xml b/core/designsystem/src/main/res/drawable/ic_plus_study.xml new file mode 100644 index 00000000..7ed2f364 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_plus_study.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_star.xml b/core/designsystem/src/main/res/drawable/ic_star.xml new file mode 100644 index 00000000..6b01d0a0 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_star.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_todo_check_filled.xml b/core/designsystem/src/main/res/drawable/ic_todo_check_filled.xml new file mode 100644 index 00000000..35af0d59 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_todo_check_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_todo_check_unfilled.xml b/core/designsystem/src/main/res/drawable/ic_todo_check_unfilled.xml new file mode 100644 index 00000000..e3e07ecd --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_todo_check_unfilled.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/java/com/umcspot/spot/ui/extension/GsonExt.kt b/core/ui/src/main/java/com/umcspot/spot/ui/extension/GsonExt.kt index 31a7b184..0b8e9d15 100644 --- a/core/ui/src/main/java/com/umcspot/spot/ui/extension/GsonExt.kt +++ b/core/ui/src/main/java/com/umcspot/spot/ui/extension/GsonExt.kt @@ -1,10 +1,13 @@ package com.umcspot.spot.ui.extension -import com.google.gson.Gson +import android.content.Context +import android.net.Uri import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody +import com.google.gson.Gson +import java.io.File fun Any.toRequestBody(): RequestBody { val jsonString = Gson().toJson(this) @@ -14,4 +17,16 @@ fun Any.toRequestBody(): RequestBody { fun Any.toMultipartBodyPart(name: String): MultipartBody.Part { val requestBody = this.toRequestBody() return MultipartBody.Part.createFormData(name, null, requestBody) +} + +fun Uri.toFile(context: Context): File? { + val file = File(context.cacheDir, "memoir_${System.currentTimeMillis()}.jpg") + return try { + context.contentResolver.openInputStream(this)?.use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + file + } catch (e: Exception) { + null + } } \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt b/data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt index 94d11870..4b97da5a 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt @@ -5,12 +5,20 @@ import com.umcspot.spot.model.RecruitingStatus import com.umcspot.spot.model.RecruitingStudySort import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.study.dto.request.MemoirCreateRequestDto import com.umcspot.spot.network.model.NullResultResponse import com.umcspot.spot.study.dto.request.StudyRequestDto import com.umcspot.spot.study.dto.response.CreateStudyResponseDto -import com.umcspot.spot.study.dto.response.StudyApplication +import com.umcspot.spot.study.dto.response.MemoirCreateResponseDto +import com.umcspot.spot.study.dto.response.StudyDetailResponseDto +import com.umcspot.spot.study.dto.response.StudyMemberResponseDto +import com.umcspot.spot.study.dto.response.StudyMemoirResponseDto +import com.umcspot.spot.study.dto.response.StudyMonthlyScheduleResponseDto import com.umcspot.spot.study.dto.response.StudyApplicationResponseDto import com.umcspot.spot.study.dto.response.StudyResponseDto +import com.umcspot.spot.study.dto.response.StudyScheduleResponseDto +import com.umcspot.spot.study.dto.response.TodoCreateResponseDto +import com.umcspot.spot.study.dto.response.TodoQueryResponseDto import java.io.File interface StudyDataSource { @@ -36,7 +44,50 @@ interface StudyDataSource { size: Int, ): BaseResponse - suspend fun createStudy(request: StudyRequestDto, imageFile: File?): BaseResponse + suspend fun createStudy( + request: StudyRequestDto, + imageFile: File? + ): BaseResponse + + suspend fun getStudyDetail(studyId: Long): BaseResponse + + suspend fun getStudyMembers(studyId: Long): BaseResponse + + suspend fun getUpcomingSchedules(studyId: Long): BaseResponse + + suspend fun getMonthlySchedules( + studyId: Long, + year: Int, + month: Int + ): BaseResponse + + suspend fun createTodo( + studyId: Long, + content: String, + dueDate: String + ): BaseResponse + + suspend fun completeTodo(studyId: Long, todoId: Long): BaseResponse + + suspend fun uncompleteTodo(studyId: Long, todoId: Long): BaseResponse + + suspend fun deleteTodo(studyId: Long, todoId: Long): BaseResponse + + suspend fun getMemberTodos(studyId: Long, memberId: Long, date: String): BaseResponse + + suspend fun getStudyMemoirs(studyId: Long, cursor: Long?, size: Int): BaseResponse + + suspend fun deleteMemoir(studyId: Long, memoirId: Long): BaseResponse + + suspend fun postMemoir( + studyId: Long, + request: MemoirCreateRequestDto, + imageFiles: List + ): BaseResponse + + suspend fun postReviewReaction(studyId: Long, reviewId: Long, reaction: String): BaseResponse + + suspend fun deleteReviewReaction(studyId: Long, reviewId: Long, reaction: String): BaseResponse suspend fun getCategoryStudies( recruitingStatus : RecruitingStatus?, diff --git a/data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt b/data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt index b58b4c55..360b3c66 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt @@ -7,10 +7,19 @@ import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.network.model.BaseResponse import com.umcspot.spot.network.model.NullResultResponse import com.umcspot.spot.study.datasource.StudyDataSource +import com.umcspot.spot.study.dto.request.MemoirCreateRequestDto import com.umcspot.spot.study.dto.request.StudyRequestDto +import com.umcspot.spot.study.dto.request.TodoCreateRequestDto import com.umcspot.spot.study.dto.response.CreateStudyResponseDto +import com.umcspot.spot.study.dto.response.MemoirCreateResponseDto +import com.umcspot.spot.study.dto.response.StudyDetailResponseDto +import com.umcspot.spot.study.dto.response.StudyMemberResponseDto +import com.umcspot.spot.study.dto.response.StudyMemoirResponseDto +import com.umcspot.spot.study.dto.response.StudyMonthlyScheduleResponseDto import com.umcspot.spot.study.dto.response.StudyApplicationResponseDto import com.umcspot.spot.study.dto.response.StudyResponseDto +import com.umcspot.spot.study.dto.response.StudyScheduleResponseDto +import com.umcspot.spot.study.dto.response.TodoCreateResponseDto import com.umcspot.spot.study.service.StudyService import com.umcspot.spot.ui.extension.toMultipartBodyPart import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -71,6 +80,78 @@ class StudyDataSourceImpl @Inject constructor( return studyService.createStudy(requestPart, imagePart) } + override suspend fun getStudyDetail(studyId: Long): BaseResponse = + studyService.getStudyDetail(studyId) + + override suspend fun getStudyMembers(studyId: Long): BaseResponse = + studyService.getStudyMembers(studyId) + + override suspend fun getUpcomingSchedules(studyId: Long): BaseResponse = + studyService.getUpcomingSchedules(studyId) + + override suspend fun getMonthlySchedules( + studyId: Long, + year: Int, + month: Int + ): BaseResponse = + studyService.getMonthlySchedules(studyId, year, month) + + override suspend fun createTodo( + studyId: Long, + content: String, + dueDate: String + ): BaseResponse { + val request = TodoCreateRequestDto( + content = content, + dueDate = dueDate + ) + return studyService.createTodo(studyId, request) + } + + override suspend fun completeTodo(studyId: Long, todoId: Long): BaseResponse = + studyService.completeTodo(studyId, todoId) + + override suspend fun uncompleteTodo(studyId: Long, todoId: Long): BaseResponse = + studyService.uncompleteTodo(studyId, todoId) + + override suspend fun deleteTodo(studyId: Long, todoId: Long): BaseResponse = + studyService.deleteTodo(studyId, todoId) + + override suspend fun getMemberTodos(studyId: Long, memberId: Long, date: String) = + studyService.getMemberTodos(studyId, memberId, date) + + override suspend fun getStudyMemoirs(studyId: Long, cursor: Long?, size: Int): BaseResponse = + studyService.getStudyMemoirs(studyId, cursor, size) + + override suspend fun deleteMemoir(studyId: Long, memoirId: Long): BaseResponse = + studyService.deleteMemoir(studyId, memoirId) + + override suspend fun postMemoir( + studyId: Long, + request: MemoirCreateRequestDto, + imageFiles: List + ): BaseResponse { + val requestPart = request.toMultipartBodyPart("request") + + val imageParts = imageFiles.map { file -> + val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("imageFile", file.name, requestFile) + } + + return studyService.postMemoir( + studyId = studyId, + request = requestPart, + imageFile = imageParts.ifEmpty { null } + ) + } + + override suspend fun postReviewReaction(studyId: Long, reviewId: Long, reaction: String): BaseResponse { + return studyService.postReviewReaction(studyId, reviewId, reaction) + } + + override suspend fun deleteReviewReaction(studyId: Long, reviewId: Long, reaction: String): BaseResponse { + return studyService.deleteReviewReaction(studyId, reviewId, reaction) + } override suspend fun getCategoryStudies( recruitingStatus: RecruitingStatus?, feeCategory: FeeRange?, diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/request/MemoirCreateRequestDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/request/MemoirCreateRequestDto.kt new file mode 100644 index 00000000..5fe2594f --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/request/MemoirCreateRequestDto.kt @@ -0,0 +1,12 @@ +package com.umcspot.spot.study.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MemoirCreateRequestDto( + @SerialName("activity") val activity: String, + @SerialName("learned") val learned: String, + @SerialName("encouragement") val encouragement: String, + @SerialName("isPrivate") val isPrivate: Boolean +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/request/TodoCreateRequestDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/request/TodoCreateRequestDto.kt new file mode 100644 index 00000000..f59849ca --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/request/TodoCreateRequestDto.kt @@ -0,0 +1,12 @@ +package com.umcspot.spot.study.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TodoCreateRequestDto( + @SerialName("content") + val content: String, + @SerialName("dueDate") + val dueDate: String +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/MemoirCreateResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/MemoirCreateResponseDto.kt new file mode 100644 index 00000000..5aaa4e30 --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/MemoirCreateResponseDto.kt @@ -0,0 +1,9 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MemoirCreateResponseDto( + @SerialName("reviewId") val reviewId: Long +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/ScheduleResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/ScheduleResponseDto.kt new file mode 100644 index 00000000..b7d0a10b --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/ScheduleResponseDto.kt @@ -0,0 +1,13 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ScheduleResponseDto( + @SerialName("scheduleId") val scheduleId: Long, + @SerialName("title") val title: String, + @SerialName("startAt") val startAt: String, + @SerialName("endAt") val endAt: String, + @SerialName("isNow") val isNow: Boolean +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyDetailResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyDetailResponseDto.kt new file mode 100644 index 00000000..ab653618 --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyDetailResponseDto.kt @@ -0,0 +1,22 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class StudyDetailResponseDto( + @SerialName("id") val id: Long, + @SerialName("title") val title: String, + @SerialName("description") val description: String, + @SerialName("thumbnailUrl") val thumbnailUrl: String?, + @SerialName("categories") val categories: List, + @SerialName("statistics") val statistics: StudyStatisticsDto +) + +@Serializable +data class StudyStatisticsDto( + @SerialName("totalMembers") val totalMembers: Int, + @SerialName("currentMembers") val currentMembers: Int, + @SerialName("likeCount") val likeCount: Int, + @SerialName("hitCount") val hitCount: Int +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyMemberResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyMemberResponseDto.kt new file mode 100644 index 00000000..1eed6d83 --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyMemberResponseDto.kt @@ -0,0 +1,24 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class StudyMemberResponseDto( + @SerialName("members") + val members: List, + @SerialName("totalMembers") + val totalMembers: Int +) + +@Serializable +data class MemberDto( + @SerialName("memberId") + val memberId: Long, + @SerialName("nickname") + val nickname: String, + @SerialName("profileImageUrl") + val profileImageUrl: String?, + @SerialName("isOwner") + val isOwner: Boolean +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyMemoirResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyMemoirResponseDto.kt new file mode 100644 index 00000000..17b0f8ca --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyMemoirResponseDto.kt @@ -0,0 +1,54 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class StudyMemoirResponseDto( + @SerialName("reviews") val memoirs: List, + @SerialName("hasNext") val hasNext: Boolean, + @SerialName("nextCursor") val nextCursor: Long?, + @SerialName("totalElements") val totalElements: Int +) + +@Serializable +data class MemoirDto( + @SerialName("reviewId") val memoirId: Long, + @SerialName("writer") val writer: MemoirWriterDto, + @SerialName("content") val content: MemoirContentDto, + @SerialName("reactionCounts") val reactionCounts: MemoirReactionCountsDto, + @SerialName("reactions") val reactions: MemoirReactionStatusDto, + @SerialName("isPrivate") val isPrivate: Boolean, + @SerialName("createdAt") val createdAt: String +) + +@Serializable +data class MemoirWriterDto( + @SerialName("memberId") val memberId: Long, + @SerialName("nickname") val nickname: String, + @SerialName("profileImageUrl") val profileImageUrl: String? +) + +@Serializable +data class MemoirContentDto( + @SerialName("activity") val activity: String, + @SerialName("learned") val learned: String, + @SerialName("encouragement") val encouragement: String, + @SerialName("imageUrl") val imageUrl: String? +) + +@Serializable +data class MemoirReactionCountsDto( + @SerialName("fireCount") val fireCount: Int, + @SerialName("heartCount") val heartCount: Int, + @SerialName("starCount") val starCount: Int, + @SerialName("smileCount") val smileCount: Int +) + +@Serializable +data class MemoirReactionStatusDto( + @SerialName("isFired") val isFired: Boolean, + @SerialName("isHearted") val isHearted: Boolean, + @SerialName("isStarred") val isStarred: Boolean, + @SerialName("isSmiled") val isSmiled: Boolean +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyMonthlyScheduleResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyMonthlyScheduleResponseDto.kt new file mode 100644 index 00000000..1d0b8d12 --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyMonthlyScheduleResponseDto.kt @@ -0,0 +1,10 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class StudyMonthlyScheduleResponseDto( + @SerialName("schedules") val schedules: List, + @SerialName("totalCount") val totalCount: Int +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyScheduleResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyScheduleResponseDto.kt new file mode 100644 index 00000000..fc5c423b --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyScheduleResponseDto.kt @@ -0,0 +1,12 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class StudyScheduleResponseDto( + @SerialName("schedules") + val schedules: List, + @SerialName("totalCount") + val totalCount: Int +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/TodoCreateResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/TodoCreateResponseDto.kt new file mode 100644 index 00000000..f6638ed9 --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/TodoCreateResponseDto.kt @@ -0,0 +1,10 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TodoCreateResponseDto( + @SerialName("todoId") + val todoId: Long +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/dto/response/TodoQueryResponseDto.kt b/data/study/src/main/java/com/umcspot/spot/study/dto/response/TodoQueryResponseDto.kt new file mode 100644 index 00000000..364c5b92 --- /dev/null +++ b/data/study/src/main/java/com/umcspot/spot/study/dto/response/TodoQueryResponseDto.kt @@ -0,0 +1,24 @@ +package com.umcspot.spot.study.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TodoQueryResponseDto( + @SerialName("pending") + val pending: List, + @SerialName("completed") + val completed: List +) + +@Serializable +data class TodoItemDto( + @SerialName("id") + val id: Long, + @SerialName("content") + val content: String, + @SerialName("dueDate") + val dueDate: String, + @SerialName("isCompleted") + val isCompleted: Boolean +) \ No newline at end of file diff --git a/data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt b/data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt index f9079d9d..ea3f0711 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt @@ -1,16 +1,35 @@ package com.umcspot.spot.study.mapper +import com.umcspot.spot.study.dto.request.MemoirCreateRequestDto import com.umcspot.spot.model.toImageRef import com.umcspot.spot.study.dto.request.StudyRequestDto +import com.umcspot.spot.study.dto.response.MemberDto +import com.umcspot.spot.study.dto.response.MemoirDto +import com.umcspot.spot.study.dto.response.ScheduleResponseDto +import com.umcspot.spot.study.dto.response.StudyDetailResponseDto import com.umcspot.spot.study.dto.response.Study import com.umcspot.spot.study.dto.response.StudyApplication import com.umcspot.spot.study.dto.response.StudyApplicationResponseDto import com.umcspot.spot.study.dto.response.StudyResponseDto +import com.umcspot.spot.study.dto.response.TodoCreateResponseDto +import com.umcspot.spot.study.dto.response.TodoItemDto +import com.umcspot.spot.study.dto.response.TodoQueryResponseDto +import com.umcspot.spot.study.model.MemoirCreateModel +import com.umcspot.spot.study.model.MemoirModel +import com.umcspot.spot.study.model.MemoirReactionCounts +import com.umcspot.spot.study.model.MemoirReactionStatus import com.umcspot.spot.study.model.StudyApplicationResult import com.umcspot.spot.study.model.StudyApplicationResultList import com.umcspot.spot.study.model.StudyCreateModel +import com.umcspot.spot.study.model.StudyDetailModel +import com.umcspot.spot.study.model.StudyMemberModel +import com.umcspot.spot.study.model.StudyRecentMemoirModel import com.umcspot.spot.study.model.StudyResult import com.umcspot.spot.study.model.StudyResultList +import com.umcspot.spot.study.model.StudyScheduleModel +import com.umcspot.spot.study.model.TodoModel +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter fun StudyCreateModel.toData(): StudyRequestDto = StudyRequestDto( name = this.name, @@ -45,6 +64,89 @@ fun StudyResponseDto.toDomainList(): StudyResultList = nextCursor = this.nextCursor?.toLong() ) + +fun MemberDto.toDomain(): StudyMemberModel = StudyMemberModel( + id = this.memberId.toLong(), + name = this.nickname, + profileUrl = this.profileImageUrl, + isLeader = this.isOwner +) + +fun StudyDetailResponseDto.toDomain(): StudyDetailModel = StudyDetailModel( + id = this.id, + title = this.title, + description = this.description, + thumbnailUrl = this.thumbnailUrl, + categories = this.categories, + totalMembers = this.statistics.totalMembers, + currentMembers = this.statistics.currentMembers, + likeCount = this.statistics.likeCount, + hitCount = this.statistics.hitCount +) + +fun ScheduleResponseDto.toDomain(): StudyScheduleModel = StudyScheduleModel( + id = this.scheduleId, + title = this.title, + startAt = LocalDateTime.parse(this.startAt, DateTimeFormatter.ISO_DATE_TIME), + endAt = LocalDateTime.parse(this.endAt, DateTimeFormatter.ISO_DATE_TIME), + isNow = this.isNow +) + +fun MemoirDto.toDetailModel(): MemoirModel = MemoirModel( + memoirId = this.memoirId, + memberId = this.writer.memberId, + nickname = this.writer.nickname, + profileImageUrl = this.writer.profileImageUrl ?: "", + activity = this.content.activity, + learned = this.content.learned, + encouragement = this.content.encouragement, + imageUrl = this.content.imageUrl, + reactionCounts = MemoirReactionCounts( + fireCount = this.reactionCounts.fireCount, + heartCount = this.reactionCounts.heartCount, + starCount = this.reactionCounts.starCount, + smileCount = this.reactionCounts.smileCount + ), + reactions = MemoirReactionStatus( + isFired = this.reactions.isFired, + isHearted = this.reactions.isHearted, + isStarred = this.reactions.isStarred, + isSmiled = this.reactions.isSmiled + ), + isPrivate = this.isPrivate, + createdAt = this.createdAt, + isMyMemoir = false +) + +fun MemoirDto.toDomain(): StudyRecentMemoirModel = StudyRecentMemoirModel( + id = this.memoirId, + writerNickname = this.writer.nickname, + writerProfileUrl = this.writer.profileImageUrl, + activityContent = this.content.activity, + thumbnailUrl = this.content.imageUrl, + isPrivate = this.isPrivate +) +fun TodoCreateResponseDto.toDomain(): Long { + return this.todoId +} + +fun TodoQueryResponseDto.toDomain(memberId: String): List { + return (pending + completed).map { it.toDomain(memberId) } +} + +fun TodoItemDto.toDomain(memberId: String): TodoModel = TodoModel( + id = this.id, + memberId = memberId, + content = this.content, + isCompleted = this.isCompleted, +) + +fun MemoirCreateModel.toData(): MemoirCreateRequestDto = MemoirCreateRequestDto( + activity = this.activity, + learned = this.learned, + encouragement = this.encouragement, + isPrivate = this.isPrivate +) fun StudyApplicationResponseDto.toDomainList(): StudyApplicationResultList = StudyApplicationResultList(applies = this.applies.map {it.toDomain()}) diff --git a/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt b/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt index 3ea1f00f..d37e150f 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt @@ -7,10 +7,19 @@ import com.umcspot.spot.model.RecruitingStudySort import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.study.datasource.StudyDataSource import com.umcspot.spot.study.mapper.toData +import com.umcspot.spot.study.mapper.toDetailModel +import com.umcspot.spot.study.mapper.toDomain +import com.umcspot.spot.study.model.MemoirCreateModel +import com.umcspot.spot.study.model.MemoirModel import com.umcspot.spot.study.mapper.toDomainList import com.umcspot.spot.study.model.StudyApplicationResultList import com.umcspot.spot.study.model.StudyCreateModel +import com.umcspot.spot.study.model.StudyDetailModel +import com.umcspot.spot.study.model.StudyMemberModel +import com.umcspot.spot.study.model.StudyRecentMemoirModel import com.umcspot.spot.study.model.StudyResultList +import com.umcspot.spot.study.model.StudyScheduleModel +import com.umcspot.spot.study.model.TodoModel import com.umcspot.spot.study.repository.StudyRepository import java.io.File import javax.inject.Inject @@ -117,6 +126,136 @@ class StudyRepositoryImpl @Inject constructor( response.result.studyId } + override suspend fun getStudyDetail(studyId: Long): Result = + runCatching { + val response = studyDataSource.getStudyDetail(studyId) + response.result.toDomain() + } + + override suspend fun getStudyMembers(studyId: Long): Result> = + runCatching { + val response = studyDataSource.getStudyMembers(studyId) + response.result.members.map { it.toDomain() } + } + + override suspend fun getUpcomingSchedules(studyId: Long): Result> = + runCatching { + val response = studyDataSource.getUpcomingSchedules(studyId) + response.result.schedules.map { it.toDomain() } + } + + override suspend fun getMonthlySchedules(studyId: Long, year: Int, month: Int): Result> = + runCatching { + val response = studyDataSource.getMonthlySchedules(studyId, year, month) + response.result.schedules.map { it.toDomain() } + } + + override suspend fun createTodo( + studyId: Long, + content: String, + dueDate: String + ): Result = runCatching { + val response = studyDataSource.createTodo( + studyId = studyId, + content = content, + dueDate = dueDate + ) + + if (!response.isSuccess) { + throw Exception(response.message ?: "투두 생성 실패") + } + + response.result.toDomain() + } + + override suspend fun completeTodo(studyId: Long, todoId: Long): Result = runCatching { + val response = studyDataSource.completeTodo(studyId, todoId) + if (!response.isSuccess) throw Exception(response.message ?: "완료 처리 실패") + Unit + } + + override suspend fun uncompleteTodo(studyId: Long, todoId: Long): Result = runCatching { + val response = studyDataSource.uncompleteTodo(studyId, todoId) + if (!response.isSuccess) throw Exception(response.message ?: "미완료 처리 실패") + Unit + } + + override suspend fun deleteTodo(studyId: Long, todoId: Long): Result = runCatching { + val response = studyDataSource.deleteTodo(studyId, todoId) + if (!response.isSuccess) throw Exception(response.message ?: "삭제 처리에 실패했습니다.") + Unit + } + + override suspend fun getMemberTodos( + studyId: Long, + memberId: Long, + date: String + ): Result> = runCatching { + val response = studyDataSource.getMemberTodos(studyId, memberId, date) + if (response.isSuccess && response.result != null) { + response.result.toDomain(memberId.toString()) + } else { + throw Exception(response.message ?: "조회 실패") + } + } + + override suspend fun getFullStudyMemoirs( + studyId: Long, + cursor: Long?, + size: Int + ): Result> = runCatching { + val response = studyDataSource.getStudyMemoirs(studyId, cursor, size) + if (!response.isSuccess) throw Exception(response.message) + + response.result.memoirs.map { it.toDetailModel() } + } + + override suspend fun getStudyRecentMemoirs(studyId: Long): Result> = + runCatching { + val response = studyDataSource.getStudyMemoirs(studyId, cursor = null, size = 5) + response.result.memoirs.map { it.toDomain() } + } + + override suspend fun deleteMemoir(studyId: Long, reviewId: Long): Result = runCatching { + val response = studyDataSource.deleteMemoir(studyId, reviewId) + if (!response.isSuccess) throw Exception(response.message ?: "회고록 삭제 실패") + Unit + } + + override suspend fun postMemoir( + studyId: Long, + memoir: MemoirCreateModel, + imageFiles: List + ): Result = runCatching { + val requestDto = memoir.toData() + + val response = studyDataSource.postMemoir( + studyId = studyId, + request = requestDto, + imageFiles = imageFiles + ) + + if (!response.isSuccess) { + throw Exception(response.message ?: "회고록 작성 실패") + } + + response.result.reviewId + } + + override suspend fun postReviewReaction(studyId: Long, reviewId: Long, reaction: String): Result { + return runCatching { + val response = studyDataSource.postReviewReaction(studyId, reviewId, reaction) + if (response.isSuccess) response.result else throw Exception(response.message) + } + } + + override suspend fun deleteReviewReaction(studyId: Long, reviewId: Long, reaction: String): Result { + return runCatching { + val response = studyDataSource.deleteReviewReaction(studyId, reviewId, reaction) + if (response.isSuccess) response.result else throw Exception(response.message) + } + } + override suspend fun getCategoryStudies( recruitingStatus: RecruitingStatus?, feeRange: FeeRange?, diff --git a/data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt b/data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt index 67623c03..1e1d793f 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt @@ -5,11 +5,22 @@ import com.umcspot.spot.model.RecruitingStatus import com.umcspot.spot.model.RecruitingStudySort import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.study.dto.request.TodoCreateRequestDto import com.umcspot.spot.network.model.NullResultResponse import com.umcspot.spot.study.dto.response.CreateStudyResponseDto +import com.umcspot.spot.study.dto.response.MemoirCreateResponseDto +import com.umcspot.spot.study.dto.response.StudyDetailResponseDto +import com.umcspot.spot.study.dto.response.StudyMemberResponseDto +import com.umcspot.spot.study.dto.response.StudyMemoirResponseDto +import com.umcspot.spot.study.dto.response.StudyMonthlyScheduleResponseDto import com.umcspot.spot.study.dto.response.StudyApplicationResponseDto import com.umcspot.spot.study.dto.response.StudyResponseDto +import com.umcspot.spot.study.dto.response.StudyScheduleResponseDto +import com.umcspot.spot.study.dto.response.TodoCreateResponseDto +import com.umcspot.spot.study.dto.response.TodoQueryResponseDto import okhttp3.MultipartBody +import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Multipart import retrofit2.http.POST @@ -73,6 +84,7 @@ interface StudyService { @Query("size") size: Int ): BaseResponse + // 스터디 생성 @Multipart @POST("/api/studies") suspend fun createStudy( @@ -80,6 +92,108 @@ interface StudyService { @Part imageFile: MultipartBody.Part? ): BaseResponse + // 스터디 디테일 조회 + @GET("/api/studies/{studyId}/info") + suspend fun getStudyDetail( + @Path("studyId") studyId: Long + ): BaseResponse + + // 스터디 멤버 조회 + @GET("/api/studies/{studyId}/members") + suspend fun getStudyMembers( + @Path("studyId") studyId: Long + ): BaseResponse + + // 다가오는 일정 조회 + @GET("/api/studies/{studyId}/schedules/upcoming") + suspend fun getUpcomingSchedules( + @Path("studyId") studyId: Long + ): BaseResponse + + // 해당 날짜 일정 조회 + @GET("/api/studies/{studyId}/schedules/monthly") + suspend fun getMonthlySchedules( + @Path("studyId") studyId: Long, + @Query("year") year: Int, + @Query("month") month: Int + ): BaseResponse + + // 투두 리스트 만들기 + @POST("/api/studies/{studyId}/todos") + suspend fun createTodo( + @Path("studyId") studyId: Long, + @Body request: TodoCreateRequestDto + ): BaseResponse + + // 투두 리스트 완료 + @POST("/api/studies/{studyId}/todos/{todoId}/complete") + suspend fun completeTodo( + @Path("studyId") studyId: Long, + @Path("todoId") todoId: Long + ): BaseResponse + + // 투두 리스트 미완료 + @POST("/api/studies/{studyId}/todos/{todoId}/uncomplete") + suspend fun uncompleteTodo( + @Path("studyId") studyId: Long, + @Path("todoId") todoId: Long + ): BaseResponse + + // 투두 삭제 + @DELETE("/api/studies/{studyId}/todos/{todoId}") + suspend fun deleteTodo( + @Path("studyId") studyId: Long, + @Path("todoId") todoId: Long + ): BaseResponse + + // 투두 리스트 조회 + @GET("/api/studies/{studyId}/todos/members/{memberId}") + suspend fun getMemberTodos( + @Path("studyId") studyId: Long, + @Path("memberId") memberId: Long, + @Query("date") date: String + ): BaseResponse + + // 스터디 회고록 조회 + @GET("/api/studies/{studyId}/reviews") + suspend fun getStudyMemoirs( + @Path("studyId") studyId: Long, + @Query("cursor") cursor: Long? = null, + @Query("size") size: Int = 10 + ): BaseResponse + + // 스터디 회고록 삭제 + @DELETE("/api/studies/{studyId}/reviews/{reviewId}") + suspend fun deleteMemoir( + @Path("studyId") studyId: Long, + @Path("reviewId") reviewId: Long + ): BaseResponse + + // 회고록 작성 + @Multipart + @POST("/api/studies/{studyId}/reviews") + suspend fun postMemoir( + @Path("studyId") studyId: Long, + @Part request: MultipartBody.Part, + @Part imageFile: List? + ): BaseResponse + + // 회고록 반응 추가 + @POST("/api/studies/{studyId}/reviews/{reviewId}/reactions") + suspend fun postReviewReaction( + @Path("studyId") studyId: Long, + @Path("reviewId") reviewId: Long, + @Query("reaction") reaction: String + ): BaseResponse + + // 회고록 반응 삭제 + @DELETE("/api/studies/{studyId}/reviews/{reviewId}/reactions") + suspend fun deleteReviewReaction( + @Path("studyId") studyId: Long, + @Path("reviewId") reviewId: Long, + @Query("reaction") reaction: String + ): BaseResponse + @GET("/api/studies/me") suspend fun getMyPageStudy( @Query("statuses") statuses: List, diff --git a/domain/study/src/main/java/com/umcspot/spot/study/model/MemoirCreateModel.kt b/domain/study/src/main/java/com/umcspot/spot/study/model/MemoirCreateModel.kt new file mode 100644 index 00000000..4bff4a9b --- /dev/null +++ b/domain/study/src/main/java/com/umcspot/spot/study/model/MemoirCreateModel.kt @@ -0,0 +1,8 @@ +package com.umcspot.spot.study.model + +data class MemoirCreateModel( + val activity: String, + val learned: String, + val encouragement: String, + val isPrivate: Boolean +) \ No newline at end of file diff --git a/domain/study/src/main/java/com/umcspot/spot/study/model/MemoirModel.kt b/domain/study/src/main/java/com/umcspot/spot/study/model/MemoirModel.kt new file mode 100644 index 00000000..390d4efc --- /dev/null +++ b/domain/study/src/main/java/com/umcspot/spot/study/model/MemoirModel.kt @@ -0,0 +1,31 @@ +package com.umcspot.spot.study.model + +data class MemoirModel( + val memoirId: Long, + val memberId: Long, + val nickname: String, + val profileImageUrl: String, + val activity: String, + val learned: String, + val encouragement: String, + val imageUrl: String?, + val reactionCounts: MemoirReactionCounts, + val reactions: MemoirReactionStatus, + val isPrivate: Boolean, + val createdAt: String, + val isMyMemoir: Boolean = false +) + +data class MemoirReactionCounts( + val fireCount: Int, + val heartCount: Int, + val starCount: Int, + val smileCount: Int +) + +data class MemoirReactionStatus( + val isFired: Boolean, + val isHearted: Boolean, + val isStarred: Boolean, + val isSmiled: Boolean +) \ No newline at end of file diff --git a/domain/study/src/main/java/com/umcspot/spot/study/model/StudyDetailModel.kt b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyDetailModel.kt new file mode 100644 index 00000000..26e99035 --- /dev/null +++ b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyDetailModel.kt @@ -0,0 +1,13 @@ +package com.umcspot.spot.study.model + +data class StudyDetailModel( + val id: Long, + val title: String, + val description: String, + val thumbnailUrl: String?, + val categories: List, + val totalMembers: Int, + val currentMembers: Int, + val likeCount: Int, + val hitCount: Int +) \ No newline at end of file diff --git a/domain/study/src/main/java/com/umcspot/spot/study/model/StudyMemberModel.kt b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyMemberModel.kt new file mode 100644 index 00000000..c85c38a6 --- /dev/null +++ b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyMemberModel.kt @@ -0,0 +1,8 @@ +package com.umcspot.spot.study.model + +data class StudyMemberModel( + val id: Long, + val name: String, + val profileUrl: String?, + val isLeader: Boolean +) \ No newline at end of file diff --git a/domain/study/src/main/java/com/umcspot/spot/study/model/StudyRecentMemoirModel.kt b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyRecentMemoirModel.kt new file mode 100644 index 00000000..e7d70280 --- /dev/null +++ b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyRecentMemoirModel.kt @@ -0,0 +1,10 @@ +package com.umcspot.spot.study.model + +data class StudyRecentMemoirModel( + val id: Long, + val writerNickname: String, + val writerProfileUrl: String?, + val activityContent: String, + val thumbnailUrl: String?, + val isPrivate: Boolean +) \ No newline at end of file diff --git a/domain/study/src/main/java/com/umcspot/spot/study/model/StudyScheduleModel.kt b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyScheduleModel.kt new file mode 100644 index 00000000..ff4b41fc --- /dev/null +++ b/domain/study/src/main/java/com/umcspot/spot/study/model/StudyScheduleModel.kt @@ -0,0 +1,11 @@ +package com.umcspot.spot.study.model + +import java.time.LocalDateTime + +data class StudyScheduleModel( + val id: Long, + val title: String, + val startAt: LocalDateTime, + val endAt: LocalDateTime, + val isNow: Boolean +) \ No newline at end of file diff --git a/domain/study/src/main/java/com/umcspot/spot/study/model/TodoModel.kt b/domain/study/src/main/java/com/umcspot/spot/study/model/TodoModel.kt new file mode 100644 index 00000000..37b488c5 --- /dev/null +++ b/domain/study/src/main/java/com/umcspot/spot/study/model/TodoModel.kt @@ -0,0 +1,8 @@ +package com.umcspot.spot.study.model + +data class TodoModel( + val id: Long, + val memberId: String, + val content: String, + val isCompleted: Boolean +) \ No newline at end of file diff --git a/domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt b/domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt index 53fe32f4..0fae1c69 100644 --- a/domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt +++ b/domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt @@ -4,10 +4,16 @@ import com.umcspot.spot.model.FeeRange import com.umcspot.spot.model.RecruitingStatus import com.umcspot.spot.model.RecruitingStudySort import com.umcspot.spot.model.StudyTheme -import com.umcspot.spot.study.model.StudyApplicationResult +import com.umcspot.spot.study.model.MemoirCreateModel +import com.umcspot.spot.study.model.MemoirModel import com.umcspot.spot.study.model.StudyApplicationResultList import com.umcspot.spot.study.model.StudyCreateModel +import com.umcspot.spot.study.model.StudyDetailModel +import com.umcspot.spot.study.model.StudyMemberModel +import com.umcspot.spot.study.model.StudyRecentMemoirModel import com.umcspot.spot.study.model.StudyResultList +import com.umcspot.spot.study.model.StudyScheduleModel +import com.umcspot.spot.study.model.TodoModel import java.io.File interface StudyRepository { @@ -43,6 +49,53 @@ interface StudyRepository { suspend fun createStudy(studyCreateModel: StudyCreateModel, imageFile: File?): Result + suspend fun getStudyDetail(studyId: Long): Result + + suspend fun getStudyMembers(studyId: Long): Result> + + suspend fun getUpcomingSchedules(studyId: Long): Result> + + suspend fun getMonthlySchedules( + studyId: Long, + year: Int, + month: Int + ): Result> + + suspend fun createTodo( + studyId: Long, + content: String, + dueDate: String + ): Result + + suspend fun completeTodo(studyId: Long, todoId: Long): Result + + suspend fun uncompleteTodo(studyId: Long, todoId: Long): Result + + suspend fun deleteTodo(studyId: Long, todoId: Long): Result + + suspend fun getMemberTodos(studyId: Long, memberId: Long, date: String): Result> + + suspend fun getFullStudyMemoirs( + studyId: Long, + cursor: Long?, + size: Int + ): Result> + + suspend fun deleteMemoir(studyId: Long, reviewId: Long): Result + + suspend fun getStudyRecentMemoirs(studyId: Long): Result> + + suspend fun postMemoir( + studyId: Long, + memoir: MemoirCreateModel, + imageFiles: List + ): Result + + suspend fun postReviewReaction(studyId: Long, reviewId: Long, reaction: String): Result + + suspend fun deleteReviewReaction(studyId: Long, reviewId: Long, reaction: String): Result + + suspend fun getCategoryStudies( recruitingStatus: RecruitingStatus?, feeRange: FeeRange?, diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt index 0b8d315c..61cb1fd8 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt @@ -1,5 +1,6 @@ package com.umcspot.spot.main +import android.util.Log import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.foundation.layout.PaddingValues @@ -38,6 +39,7 @@ import com.umcspot.spot.mypage.waiting.navigation.navigateToWaitingStudy import com.umcspot.spot.mypage.waiting.navigation.waitingStudyGraph import com.umcspot.spot.signup.navigation.navigateToLanding import com.umcspot.spot.signup.navigation.signupGraph +import com.umcspot.spot.study.detail.model.StudyDetailTab import com.umcspot.spot.study.detail.navigation.navigateToStudyDetail import com.umcspot.spot.study.detail.navigation.studyDetailGraph import com.umcspot.spot.study.my.navigation.myStudyGraph @@ -58,7 +60,9 @@ fun MainNavHost( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), onRegisterScrollToTop: ((() -> Unit)?) -> Unit, - onBackRequest : () -> Unit, + onStudyTabChanged: (StudyDetailTab) -> Unit, + currentStudyDetailTab: StudyDetailTab, + onBackRequest : () -> Unit ) { val clearStackNavOptions = navOptions { popUpTo(0) { inclusive = true } @@ -111,7 +115,12 @@ fun MainNavHost( onAcceptFilterClick = { navigator.popBackStack() } ) - myStudyGraph() + myStudyGraph( + contentPadding = contentPadding, + navigateToStudyDetail = { studyId -> + navigator.navController.navigateToStudyDetail(studyId) + } + ) jjimGraph( contentPadding = contentPadding, @@ -277,7 +286,14 @@ fun MainNavHost( studyDetailGraph( contentPadding = contentPadding, - onBackClick = { navigator.navigateToMyStudy(clearStackNavOptions) } + onDetailBackClick = { + navigator.navigateToMyStudy(clearStackNavOptions) + }, + onMemoirPostBackClick = { + navigator.popBackStack() + }, + onTabChanged = onStudyTabChanged, + currentTab = currentStudyDetailTab ) } } \ No newline at end of file diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt index a8b16c35..cf3253b4 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt @@ -18,7 +18,6 @@ import com.umcspot.spot.feature.board.main.navigation.Board import com.umcspot.spot.feature.board.main.navigation.navigateToBoard import com.umcspot.spot.feature.board.post.content.navigation.POST_CONTENT_ROUTE import com.umcspot.spot.feature.board.post.posting.navigation.Posting -import com.umcspot.spot.home.navigation.Home import com.umcspot.spot.home.navigation.navigateToHome import com.umcspot.spot.jjim.navigation.JJim import com.umcspot.spot.jjim.navigation.navigateToJJim @@ -30,6 +29,7 @@ import com.umcspot.spot.mypage.participating.navigation.ParticipatingStudy import com.umcspot.spot.mypage.recruiting.application.navigation.STUDY_APPLICATION_ROUTE import com.umcspot.spot.mypage.recruiting.navigation.MyRecruitingStudy import com.umcspot.spot.mypage.waiting.navigation.WaitingStudy +import com.umcspot.spot.home.navigation.Home import com.umcspot.spot.signup.navigation.CheckList import com.umcspot.spot.signup.navigation.Landing import com.umcspot.spot.signup.navigation.Saving @@ -37,7 +37,10 @@ import com.umcspot.spot.signup.navigation.SignUp import com.umcspot.spot.signup.navigation.navigateToCheckList import com.umcspot.spot.signup.navigation.navigateToSaving import com.umcspot.spot.signup.navigation.navigateToSignUp +import com.umcspot.spot.study.detail.navigation.StudyDetail import com.umcspot.spot.study.detail.navigation.navigateToStudyDetail +import com.umcspot.spot.study.detail.navigation.navigateToStudyMemoirPost +import com.umcspot.spot.study.my.navigation.MyStudy import com.umcspot.spot.study.my.navigation.navigateToMyStudy import com.umcspot.spot.study.preferCategory.navigation.PreferCategory import com.umcspot.spot.study.preferCategory.navigation.PreferCategoryFilter @@ -88,6 +91,7 @@ class MainNavigator( MainNavTab.MYPAGE -> navController.navigateToMyPage(navOptions) } } + @Composable private fun inAnyGraph(vararg graphs: KClass<*>): Boolean { val dest = currentDestination ?: return false @@ -104,18 +108,41 @@ class MainNavigator( fun isInLanding(): Boolean = inAnyGraph(Landing::class, Saving::class) @Composable - fun showBackTopBar(): Boolean = inAnyGraph(Alert::class, RecruitingFilter::class, PreferLocationFilter::class, PreferCategoryFilter::class, - SignUp::class, CheckList::class, Posting::class, BoardList::class, JJim::class, MyPage::class, - ParticipatingStudy::class, MyRecruitingStudy::class, WaitingStudy::class, EditInterest::class, CancelMemberShip::class + fun showBackTopBar(): Boolean = inAnyGraph( + Alert::class, + RecruitingFilter::class, + PreferLocationFilter::class, + PreferCategoryFilter::class, + SignUp::class, + CheckList::class, + Posting::class, + BoardList::class, + JJim::class, + MyPage::class, + ParticipatingStudy::class, + MyRecruitingStudy::class, + WaitingStudy::class, + EditInterest::class, + CancelMemberShip::class, + MyStudy::class ) || inAnyGraphRoutes(POST_CONTENT_ROUTE) || inAnyGraphRoutes(STUDY_APPLICATION_ROUTE) @Composable - fun showToTopFab(): Boolean = inAnyGraph(Alert::class, Recruiting::class, - PreferLocation::class, PreferCategory::class, BoardList::class, JJim::class, ParticipatingStudy::class, MyRecruitingStudy::class, WaitingStudy::class + fun showToTopFab(): Boolean = inAnyGraph( + Alert::class, + Recruiting::class, + PreferLocation::class, + PreferCategory::class, + BoardList::class, + JJim::class, + ParticipatingStudy::class, + MyRecruitingStudy::class, + WaitingStudy::class, + MyStudy::class ) || inAnyGraphRoutes(STUDY_APPLICATION_ROUTE) @Composable - fun showMultipleFab(): Boolean = inAnyGraph(Home::class, BoardList::class) + fun showMultipleFab(): Boolean = inAnyGraph(Home::class, BoardList::class, StudyDetail::class) @Composable fun showBottomBar(): Boolean { @@ -193,6 +220,10 @@ class MainNavigator( navController.navigateToStudyDetail(studyId, navOptions) } + fun navigateToStudyMemoirPost(studyId: Long) { + navController.navigateToStudyMemoirPost(studyId) + } + fun navigateToHomeAfterLogin() { val navOptions = navOptions { popUpTo(Landing) { inclusive = true } 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 a8491cf3..810f129e 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 @@ -14,6 +14,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,6 +25,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.toRoute import com.umcspot.spot.alert.navigation.Alert import com.umcspot.spot.alert.navigation.navigateToAlert import com.umcspot.spot.designsystem.component.FloatingMultipleButton @@ -38,7 +40,6 @@ import com.umcspot.spot.feature.board.post.posting.navigation.navigateToPostingN import com.umcspot.spot.home.navigation.Home import com.umcspot.spot.jjim.navigation.JJim import com.umcspot.spot.main.component.MainBottomBar -import com.umcspot.spot.mypage.cancelMemberShip.CancelMemberShipScreen import com.umcspot.spot.mypage.cancelMemberShip.navigation.CancelMemberShip import com.umcspot.spot.mypage.editInterestStudy.navigation.EditInterest import com.umcspot.spot.mypage.main.navigation.MyPage @@ -48,7 +49,10 @@ import com.umcspot.spot.mypage.recruiting.navigation.MyRecruitingStudy import com.umcspot.spot.mypage.waiting.navigation.WaitingStudy import com.umcspot.spot.signup.navigation.CheckList import com.umcspot.spot.signup.navigation.SignUp +import com.umcspot.spot.study.detail.model.StudyDetailTab import com.umcspot.spot.study.detail.navigation.StudyDetail +import com.umcspot.spot.study.detail.navigation.StudyMemoirPost +import com.umcspot.spot.study.my.navigation.MyStudy import com.umcspot.spot.study.preferCategory.navigation.PreferCategoryFilter import com.umcspot.spot.study.preferLocation.navigation.PreferLocationFilter import com.umcspot.spot.study.recruiting.navigation.RecruitingFilter @@ -67,6 +71,8 @@ fun MainScreen( var showBackRequestDialog by remember { mutableStateOf(false) } + var currentStudyDetailTab by rememberSaveable { mutableStateOf(StudyDetailTab.HOME) } + val hasUnreadAlert by mainViewModel.hasUnreadAlert.collectAsStateWithLifecycle() val isHome = dest?.hasRoute(Home::class) == true @@ -78,10 +84,11 @@ fun MainScreen( Scaffold( topBar = { if (!navigator.isInLanding()) { - val isRegisterOrDetail = dest?.hasRoute(RegisterStudy::class) == true || - dest?.hasRoute(StudyDetail::class) == true + val isFullPage = dest?.hasRoute(RegisterStudy::class) == true || + dest?.hasRoute(StudyDetail::class) == true || + dest?.hasRoute(StudyMemoirPost::class) == true - if (isRegisterOrDetail) { + if (isFullPage) { } else if (navigator.showBackTopBar()) { val title = when { dest?.hasRoute(Alert::class) == true -> "알림" @@ -93,6 +100,7 @@ fun MainScreen( dest?.hasRoute(Posting::class) == true -> "글쓰기" dest?.hasRoute(BoardList::class) == true -> "스터디 파트너들의 이야기" dest?.hasRoute(JJim::class) == true -> "찜한 스터디" + dest?.hasRoute(MyStudy::class) == true -> "내 스터디" dest?.hasRoute(MyPage::class) == true -> "마이페이지" dest?.hasRoute(ParticipatingStudy::class) == true -> "참여 중인 스터디" dest?.hasRoute(MyRecruitingStudy::class) == true -> "모집 중인 스터디" @@ -130,7 +138,10 @@ fun MainScreen( FabStack( showToTop = navigator.showToTopFab(), onClickToTop = { scrollToTop?.invoke() }, - showMultiple = navigator.showMultipleFab(), + showMultiple = navigator.showMultipleFab() && ( + dest?.hasRoute(BoardList::class) == true || + (dest?.hasRoute(StudyDetail::class) == true && currentStudyDetailTab == StudyDetailTab.MEMOIR) + ), onClickMultiple = { when { dest?.hasRoute(BoardList::class) == true -> { @@ -139,6 +150,12 @@ fun MainScreen( dest?.hasRoute(Home::class) == true -> { navigator.navigateToRegisterStudy() } + dest?.hasRoute(StudyDetail::class) == true -> { + val studyId = backStackEntry?.toRoute()?.studyId + if (studyId != null) { + navigator.navigateToStudyMemoirPost(studyId) + } + } } }, spacing = 12.dp, @@ -166,6 +183,8 @@ fun MainScreen( contentPadding = innerPadding, onRegisterScrollToTop = { handler -> scrollToTop = handler }, onBackRequest = { showBackRequestDialog = true }, + onStudyTabChanged = { tab -> currentStudyDetailTab = tab }, + currentStudyDetailTab = currentStudyDetailTab, ) } diff --git a/feature/study/build.gradle.kts b/feature/study/build.gradle.kts index bf92f6c4..0e81cc58 100644 --- a/feature/study/build.gradle.kts +++ b/feature/study/build.gradle.kts @@ -11,7 +11,7 @@ dependencies { implementation(projects.domain.user) implementation(projects.core.designsystem) implementation(projects.core.common) - + implementation(libs.kizitonwose.calendar.compose) implementation(libs.lottie) implementation(libs.lottie.compose) implementation(libs.material3.compose) diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt index 8d88606c..4dee36bd 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt @@ -2,78 +2,196 @@ package com.umcspot.spot.study.detail import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues 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.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import com.umcspot.spot.designsystem.component.appBar.BackTopBar -import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.detail.component.common.StudyDetailTabRow +import com.umcspot.spot.study.detail.component.common.StudyHeaderSection +import com.umcspot.spot.study.detail.model.StudyDetailState +import com.umcspot.spot.study.detail.model.StudyDetailTab +import com.umcspot.spot.study.detail.screen.StudyDetailBoardScreen +import com.umcspot.spot.study.detail.screen.StudyDetailHomeScreen +import com.umcspot.spot.study.detail.screen.StudyDetailMemoirScreen +import com.umcspot.spot.study.detail.screen.StudyDetailPlannerScreen +import com.umcspot.spot.ui.extension.screenHeightDp import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.time.LocalDate @Composable fun StudyDetailRoute( - contentPadding: PaddingValues, studyId: Long, - onBackClick: () -> Unit + onBackClick: () -> Unit, + contentPadding: PaddingValues, + onTabChanged: (StudyDetailTab) -> Unit, + initialTab: StudyDetailTab, + viewModel: StudyDetailViewModel = hiltViewModel() ) { - BackHandler { - onBackClick() - } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var selectedTab by rememberSaveable { mutableStateOf(initialTab) } + val scope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() - Column( - modifier = Modifier - .fillMaxSize() - .background(SpotTheme.colors.white) - ) { - Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) + LaunchedEffect(studyId) { + viewModel.fetchStudyHomeDetail(studyId) + val memberId = uiState.plannerState.selectedMemberId.toLongOrNull() + if (memberId != null) { + viewModel.fetchMemberTodos(studyId, memberId, LocalDate.now()) + } + } - BackTopBar( - title = "스터디", - onBackClick = onBackClick, - modifier = Modifier.fillMaxWidth() - ) + LaunchedEffect(selectedTab) { + onTabChanged(selectedTab) + } - StudyDetailScreen( - studyId = studyId, - modifier = Modifier.padding(bottom = contentPadding.calculateBottomPadding()) - ) + DisposableEffect(Unit) { + onDispose { onTabChanged(StudyDetailTab.HOME) } } + + BackHandler { onBackClick() } + + StudyDetailScreen( + studyId = studyId, + uiState = uiState, + selectedTab = selectedTab, + onTabSelected = { selectedTab = it }, + onDateSelected = viewModel::updateSelectedDate, + onMonthChanged = { year, month -> viewModel.fetchMonthlySchedules(studyId, year, month) }, + onAddingTodo = { + scope.launch { + delay(300) + lazyListState.animateScrollToItem(lazyListState.layoutInfo.totalItemsCount - 1, -300) + } + }, + onTodoCreate = viewModel::createTodo, + onTodoToggle = viewModel::toggleTodoStatus, + onTodoDelete = viewModel::deleteTodo, + onMemberSelected = { memberId -> + viewModel.fetchMemberTodos( + studyId = studyId, + memberId = memberId, + date = uiState.plannerState.selectedDate + ) + }, + onMemoirDelete = { memoirId -> viewModel.deleteMemoir(studyId, memoirId) }, + onMemoirEmojiToggle = viewModel::toggleMemoirReaction, + onBackClick = onBackClick, + contentPadding = contentPadding, + lazyListState = lazyListState + ) } @Composable private fun StudyDetailScreen( studyId: Long, - modifier: Modifier = Modifier + uiState: StudyDetailState, + selectedTab: StudyDetailTab, + onTabSelected: (StudyDetailTab) -> Unit, + onDateSelected: (LocalDate) -> Unit, + onMonthChanged: (Int, Int) -> Unit, + onAddingTodo: () -> Unit, + onTodoCreate: (Long, String) -> Unit, + onTodoToggle: (Long, Long, Boolean) -> Unit, + onTodoDelete: (Long, Long) -> Unit, + onMemberSelected: (Long) -> Unit, + onMemoirDelete: (Long) -> Unit, + onMemoirEmojiToggle: (Long, Long, String, Boolean) -> Unit, + onBackClick: () -> Unit, + contentPadding: PaddingValues, + lazyListState: LazyListState ) { - Column( - modifier = modifier + LazyColumn( + state = lazyListState, + modifier = Modifier .fillMaxSize() - .padding(horizontal = screenWidthDp(17.dp)), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + .background(SpotTheme.colors.white) + .imePadding() ) { - Text( - text = "스터디 상세 화면", - style = SpotTheme.typography.h1, - color = SpotTheme.colors.black - ) + item { + Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) + BackTopBar(title = "스터디", onBackClick = onBackClick) + + AsyncImage( + model = uiState.homeState.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(screenHeightDp(160.dp)), + contentScale = ContentScale.Crop + ) + + StudyHeaderSection(uiState.homeState) + } + + item { + StudyDetailTabRow(selectedTab = selectedTab, onTabSelected = onTabSelected) + Spacer(modifier = Modifier.height(screenHeightDp(18.dp))) + } + + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = screenWidthDp(17.dp)) + .padding(bottom = contentPadding.calculateBottomPadding() + screenHeightDp(20.dp)) + ) { + when (selectedTab) { + StudyDetailTab.HOME -> StudyDetailHomeScreen( + description = uiState.homeState.studyDescription, + members = uiState.homeState.members, + schedules = uiState.homeState.schedules, + recentMemoirs = uiState.homeState.recentMemoirs + ) - Spacer(modifier = Modifier.height(12.dp)) + StudyDetailTab.PLANNER -> StudyDetailPlannerScreen( + studyId = studyId, + plannerState = uiState.plannerState, + members = uiState.homeState.members, + onDateSelected = onDateSelected, + onMonthChanged = onMonthChanged, + onAddingTodo = onAddingTodo, + onTodoCreate = onTodoCreate, + onTodoToggle = onTodoToggle, + onTodoDelete = onTodoDelete, + onMemberSelected = onMemberSelected + ) - Text( - text = "생성된 스터디 ID: $studyId", - style = SpotTheme.typography.regular_500, - color = SpotTheme.colors.B500 - ) + StudyDetailTab.BOARD -> StudyDetailBoardScreen() + StudyDetailTab.MEMOIR -> StudyDetailMemoirScreen( + studyId = studyId, + memoirs = uiState.memoirState.memoirs, + onDeleteMemoir = onMemoirDelete, + onEmojiToggle = { memoirId, type, isSelected -> + onMemoirEmojiToggle(studyId, memoirId, type, isSelected) + } + ) + } + } + } } } \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt new file mode 100644 index 00000000..ceeef695 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt @@ -0,0 +1,311 @@ +package com.umcspot.spot.study.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.study.detail.model.StudyDetailSideEffect +import com.umcspot.spot.study.detail.model.StudyDetailState +import com.umcspot.spot.study.model.MemoirCreateModel +import com.umcspot.spot.study.model.TodoModel +import com.umcspot.spot.study.repository.StudyRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.async +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 java.io.File +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import kotlin.collections.map + +@HiltViewModel +class StudyDetailViewModel @Inject constructor( + private val studyRepository: StudyRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(StudyDetailState()) + val uiState = _uiState.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() + + fun fetchStudyHomeDetail(studyId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + val detailDeferred = async { studyRepository.getStudyDetail(studyId) } + val membersDeferred = async { studyRepository.getStudyMembers(studyId) } + val schedulesDeferred = async { studyRepository.getUpcomingSchedules(studyId) } + val memoirsDeferred = async { studyRepository.getStudyRecentMemoirs(studyId) } + + + detailDeferred.await().onSuccess { model -> + _uiState.update { it.copy(homeState = it.homeState.copy( + studyTitle = model.title, + studyDescription = model.description, + thumbnailUrl = model.thumbnailUrl, + categories = model.categories.toPersistentList(), + currentMembers = model.currentMembers, + totalMembers = model.totalMembers, + likeCount = model.likeCount, + hitCount = model.hitCount + ))} + }.onFailure { emitError(it) } + + membersDeferred.await().onSuccess { members -> + _uiState.update { it.copy(homeState = it.homeState.copy(members = members.toPersistentList())) } + }.onFailure { emitError(it) } + + schedulesDeferred.await().onSuccess { schedules -> + _uiState.update { it.copy(homeState = it.homeState.copy(schedules = schedules.toPersistentList())) } + }.onFailure { emitError(it) } + + memoirsDeferred.await().onSuccess { memoirs -> + _uiState.update { it.copy(homeState = it.homeState.copy(recentMemoirs = memoirs.toPersistentList())) } + }.onFailure { emitError(it) } + + _uiState.update { it.copy(isLoading = false) } + } + } + + fun fetchMonthlySchedules(studyId: Long, year: Int, month: Int) { + viewModelScope.launch { + studyRepository.getMonthlySchedules(studyId, year, month).onSuccess { schedules -> + _uiState.update { state -> + val updatedPlanner = state.plannerState.copy( + monthlySchedules = schedules.toPersistentList() + ) + state.copy(plannerState = updatedPlanner) + } + updateFilteredSchedules() + } + } + } + + fun updateSelectedDate(date: LocalDate) { + _uiState.update { state -> + state.copy(plannerState = state.plannerState.copy(selectedDate = date)) + } + updateFilteredSchedules() + } + + private fun updateFilteredSchedules() { + _uiState.update { state -> + val date = state.plannerState.selectedDate + val filtered = state.plannerState.monthlySchedules.filter { schedule -> + val start = schedule.startAt.toLocalDate() + val end = schedule.endAt.toLocalDate() + !date.isBefore(start) && !date.isAfter(end) + }.take(2).toPersistentList() + + state.copy(plannerState = state.plannerState.copy(selectedDaySchedules = filtered)) + } + } + + fun createTodo(studyId: Long, content: String) { + val selectedDate = uiState.value.plannerState.selectedDate + val today = LocalDate.now() + + if (selectedDate.isBefore(today)) return + + viewModelScope.launch { + studyRepository.createTodo( + studyId = studyId, + content = content, + dueDate = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE) + ).onSuccess { newTodoId -> + _uiState.update { state -> + val newTodo = TodoModel( + id = newTodoId, + memberId = state.plannerState.selectedMemberId, + content = content, + isCompleted = false + ) + state.copy( + plannerState = state.plannerState.copy( + todoList = (state.plannerState.todoList + newTodo).toPersistentList() + ) + ) + } + }.onFailure { + emitError(it) + } + } + } + + fun toggleTodoStatus(studyId: Long, todoId: Long, isCurrentlyCompleted: Boolean) { + viewModelScope.launch { + val result = if (isCurrentlyCompleted) { + studyRepository.uncompleteTodo(studyId, todoId) + } else { + studyRepository.completeTodo(studyId, todoId) + } + + result.onSuccess { + _uiState.update { state -> + val newList = state.plannerState.todoList.map { todo -> + + if (todo.id == todoId) todo.copy(isCompleted = !isCurrentlyCompleted) + else todo + }.toPersistentList() + state.copy(plannerState = state.plannerState.copy(todoList = newList)) + } + } + } + } + + fun deleteTodo(studyId: Long, todoId: Long) { + viewModelScope.launch { + studyRepository.deleteTodo(studyId, todoId).onSuccess { + _uiState.update { state -> + val updatedTodo = state.plannerState.todoList + .filterNot { it.id == todoId } + .toPersistentList() + state.copy(plannerState = state.plannerState.copy(todoList = updatedTodo)) + } + }.onFailure { emitError(it) } + } + } + + fun fetchMemberTodos(studyId: Long, memberId: Long, date: LocalDate) { + viewModelScope.launch { + studyRepository.getMemberTodos(studyId, memberId, date.toString()) + .onSuccess { todoList -> + _uiState.update { state -> + state.copy( + plannerState = state.plannerState.copy( + selectedMemberId = memberId.toString(), + selectedDate = date, + todoList = todoList.toPersistentList() + ) + ) + } + } + } + } + + fun postMemoir( + studyId: Long, + activity: String, + learned: String, + encouragement: String, + isPrivate: Boolean, + imageFiles: List + ) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + val memoirModel = MemoirCreateModel( + activity = activity, + learned = learned, + encouragement = encouragement, + isPrivate = isPrivate + ) + + studyRepository.postMemoir(studyId, memoirModel, imageFiles) + .onSuccess { + _sideEffect.emit(StudyDetailSideEffect.MemoirPostSuccess) + fetchStudyHomeDetail(studyId) + } + .onFailure { emitError(it) } + + _uiState.update { it.copy(isLoading = false) } + } + } + + fun toggleMemoirReaction( + studyId: Long, + memoirId: Long, + reactionType: String, + isCurrentlySelected: Boolean + ) { + viewModelScope.launch { + // 1. 낙관적 업데이트 + updateMemoirUIState(memoirId, reactionType, !isCurrentlySelected) + + // 2. 서버 통신 (Repository API 호출) + val result = if (isCurrentlySelected) { + studyRepository.deleteReviewReaction(studyId, memoirId, reactionType) + } else { + studyRepository.postReviewReaction(studyId, memoirId, reactionType) + } + + // 3. 실패 시 롤백 + result.onFailure { error -> + updateMemoirUIState(memoirId, reactionType, isCurrentlySelected) + emitError(error) + } + } + } + + private fun updateMemoirUIState(memoirId: Long, reactionType: String, isSelected: Boolean) { + _uiState.update { state -> + val updatedMemoirs = state.memoirState.memoirs.map { memoir -> + if (memoir.memoirId == memoirId) { + val diff = if (isSelected) 1 else -1 + + memoir.copy( + reactions = when (reactionType) { + "FIRE" -> memoir.reactions.copy(isFired = isSelected) + "HEART" -> memoir.reactions.copy(isHearted = isSelected) + "STAR" -> memoir.reactions.copy(isStarred = isSelected) + "SMILE" -> memoir.reactions.copy(isSmiled = isSelected) + else -> memoir.reactions + }, + reactionCounts = when (reactionType) { + "FIRE" -> memoir.reactionCounts.copy(fireCount = (memoir.reactionCounts.fireCount + diff).coerceAtLeast(0)) + "HEART" -> memoir.reactionCounts.copy(heartCount = (memoir.reactionCounts.heartCount + diff).coerceAtLeast(0)) + "STAR" -> memoir.reactionCounts.copy(starCount = (memoir.reactionCounts.starCount + diff).coerceAtLeast(0)) + "SMILE" -> memoir.reactionCounts.copy(smileCount = (memoir.reactionCounts.smileCount + diff).coerceAtLeast(0)) + else -> memoir.reactionCounts + } + ) + } else memoir + }.toPersistentList() + state.copy(memoirState = state.memoirState.copy(memoirs = updatedMemoirs)) + } + } + + fun fetchAllMemoirs(studyId: Long, cursor: Long? = null) { + viewModelScope.launch { + // 나중에 실제 memberId + val myMemberId = -1L + + studyRepository.getFullStudyMemoirs(studyId, cursor, 20).onSuccess { memoirs -> + val processedMemoirs = memoirs.map { memoir -> + memoir.copy(isMyMemoir = memoir.memberId == myMemberId) + } + + _uiState.update { state -> + val currentList = if (cursor == null) emptyList() else state.memoirState.memoirs + state.copy( + memoirState = state.memoirState.copy( + memoirs = (currentList + processedMemoirs).toPersistentList() + ) + ) + } + }.onFailure { emitError(it) } + } + } + + fun deleteMemoir(studyId: Long, memoirId: Long) { + viewModelScope.launch { + studyRepository.deleteMemoir(studyId, memoirId).onSuccess { + _uiState.update { state -> + val updatedList = state.memoirState.memoirs + .filterNot { it.memoirId == memoirId } + .toPersistentList() + state.copy(memoirState = state.memoirState.copy(memoirs = updatedList)) + } + }.onFailure { emitError(it) } + } + } + + private suspend fun emitError(t: Throwable) { + _sideEffect.emit(StudyDetailSideEffect.ShowSnackBar(t.message ?: "데이터를 불러오는데 실패했습니다.")) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/DeleteMenuPopup.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/DeleteMenuPopup.kt new file mode 100644 index 00000000..90e93881 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/DeleteMenuPopup.kt @@ -0,0 +1,36 @@ +package com.umcspot.spot.study.detail.component.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.graphics.Color +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun DeleteMenuPopup(onDelete: () -> Unit) { + Box( + modifier = Modifier + .width(screenWidthDp(80.dp)) + .background(SpotTheme.colors.white, RoundedCornerShape(8.dp)) + .border(1.dp, SpotTheme.colors.gray200, RoundedCornerShape(8.dp)) + .noRippleClickable { onDelete() } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "삭제하기", + style = SpotTheme.typography.regular_400, + color = Color.Red + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailCategoryChip.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailCategoryChip.kt new file mode 100644 index 00000000..073ac913 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailCategoryChip.kt @@ -0,0 +1,38 @@ +package com.umcspot.spot.study.detail.component.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.B100 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun StudyDetailCategoryChip( + text: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background( + color = SpotTheme.colors.B100, + shape = RoundedCornerShape(6.dp) + ) + .padding( + horizontal = screenWidthDp(7.dp), + vertical = screenHeightDp(4.dp) + ) + ) { + Text( + text = text, + style = SpotTheme.typography.small_500, + color = SpotTheme.colors.black + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailCreateButton.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailCreateButton.kt new file mode 100644 index 00000000..87f89cd1 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailCreateButton.kt @@ -0,0 +1,66 @@ +package com.umcspot.spot.study.detail.component.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.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.res.painterResource +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.B100 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable + +@Composable +fun StudyDetailCreateButton( + text: String, + isStudyMember: Boolean, + onButtonClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(14.dp)) + .background( + if (enabled) SpotTheme.colors.B100 else SpotTheme.colors.B100.copy(alpha = 0.5f) + ) + .noRippleClickable { + if (enabled) onButtonClick() + } + .padding( + start = 10.dp, + end = if (isStudyMember) 4.dp else 10.dp, + top = 3.5.dp, + bottom = 3.5.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = text, + style = SpotTheme.typography.h5, + + color = if (enabled) SpotTheme.colors.gray500 else SpotTheme.colors.gray400 + ) + + if (isStudyMember) { + Spacer(modifier = Modifier.width(4.dp)) + + Image( + painter = painterResource(id = R.drawable.ic_plus_study), + contentDescription = null, + alpha = if (enabled) 1.0f else 0.5f + ) + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailInfoItem.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailInfoItem.kt new file mode 100644 index 00000000..4f4411f4 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailInfoItem.kt @@ -0,0 +1,48 @@ +package com.umcspot.spot.study.detail.component.common + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.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 StudyDetailInfoItem( + iconRes: Int, + text: String, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .width(screenWidthDp(56.dp)) + .padding( + horizontal = screenWidthDp(3.dp), + vertical = screenHeightDp(1.5.dp) + ) + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(screenWidthDp(14.dp)), + tint = SpotTheme.colors.black + ) + Spacer(modifier = Modifier.width(screenWidthDp(4.dp))) + Text( + text = text, + style = SpotTheme.typography.small_400, + color = SpotTheme.colors.black, + maxLines = 1 + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailTabRow.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailTabRow.kt new file mode 100644 index 00000000..1f0af66c --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyDetailTabRow.kt @@ -0,0 +1,76 @@ +package com.umcspot.spot.study.detail.component.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.detail.model.StudyDetailTab +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun StudyDetailTabRow( + selectedTab: StudyDetailTab, + onTabSelected: (StudyDetailTab) -> Unit, + modifier: Modifier = Modifier +) { + val tabs = StudyDetailTab.entries + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + screenWidthDp(42.dp), + Alignment.CenterHorizontally + ) + ) { + tabs.forEach { tab -> + val isSelected = selectedTab == tab + + Column( + modifier = Modifier + .width(screenWidthDp(50.dp)) + .noRippleClickable { onTabSelected(tab) }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.height(29.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = tab.title, + style = SpotTheme.typography.h5, + color = if (isSelected) SpotTheme.colors.black else SpotTheme.colors.gray400 + ) + } + + Box( + modifier = Modifier + .width(screenWidthDp(50.dp)) + .height(screenHeightDp(1.dp)) + .background(if (isSelected) SpotTheme.colors.B500 else Color.Transparent) + ) + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(screenHeightDp(1.dp)) + .background(SpotTheme.colors.gray200) + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyHeaderSection.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyHeaderSection.kt new file mode 100644 index 00000000..72e5de72 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyHeaderSection.kt @@ -0,0 +1,51 @@ +package com.umcspot.spot.study.detail.component.common + +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.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.designsystem.R +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.detail.mapper.formatCount +import com.umcspot.spot.study.detail.mapper.toCategoryString +import com.umcspot.spot.study.detail.model.StudyHomeState +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun StudyHeaderSection(homeState: StudyHomeState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = screenHeightDp(18.dp), horizontal = screenWidthDp(17.dp)) + ) { + Text(text = homeState.studyTitle, style = SpotTheme.typography.h2) + + Spacer(modifier = Modifier.height(screenHeightDp(7.dp))) + + Row(horizontalArrangement = Arrangement.spacedBy(screenWidthDp(4.dp))) { + StudyDetailInfoItem( + iconRes = R.drawable.ic_member, + text = "${homeState.currentMembers} / ${homeState.totalMembers}" + ) + StudyDetailInfoItem( + iconRes = R.drawable.ic_hit_count, + text = homeState.hitCount.formatCount + ) + StudyDetailInfoItem( + iconRes = R.drawable.ic_like_count, + text = homeState.likeCount.formatCount + ) + } + + Spacer(modifier = Modifier.height(screenHeightDp(7.dp))) + StudyDetailCategoryChip(text = homeState.categories.toCategoryString()) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyMemberItem.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyMemberItem.kt new file mode 100644 index 00000000..f4e6e063 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/common/StudyMemberItem.kt @@ -0,0 +1,74 @@ +package com.umcspot.spot.study.detail.component.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable + +@Composable +fun StudyMemberItem( + name: String, + profileUrl: String?, + isLeader: Boolean = false, + showLeaderIcon: Boolean = true, + isSelected: Boolean = true, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .then(if (onClick != null) Modifier.noRippleClickable { onClick() } else Modifier) + .graphicsLayer(alpha = if (isSelected) 1f else 0.5f) + ) { + Box(contentAlignment = Alignment.BottomEnd) { + AsyncImage( + model = profileUrl, + contentDescription = null, + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(SpotTheme.colors.gray100), + contentScale = ContentScale.Crop, + placeholder = painterResource(id = R.drawable.spot_logo), + error = painterResource(id = R.drawable.spot_logo) + ) + + + if (showLeaderIcon && isLeader) { + Icon( + painter = painterResource(id = R.drawable.ic_leader), + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color.Unspecified + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = name, + style = SpotTheme.typography.small_500, + color = if (isSelected) SpotTheme.colors.black else SpotTheme.colors.gray400 + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/EmojiOptionPopup.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/EmojiOptionPopup.kt new file mode 100644 index 00000000..65044c73 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/EmojiOptionPopup.kt @@ -0,0 +1,107 @@ +package com.umcspot.spot.study.detail.component.memoir + +import androidx.compose.foundation.Image +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.CircleShape +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.graphics.Color +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.B200 +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 + +@Composable +fun EmojiOptionPopup(states: List, onToggle: (Int) -> Unit) { + Row( + modifier = Modifier + .size(width = screenWidthDp(89.dp), height = screenHeightDp(26.dp)) + .background(SpotTheme.colors.white, RoundedCornerShape(screenWidthDp(10.dp))) + .border(1.dp, SpotTheme.colors.gray200, RoundedCornerShape(screenWidthDp(10.dp))) + .padding(horizontal = screenWidthDp(6.dp), vertical = screenHeightDp(4.dp)), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val icons = listOf(R.drawable.ic_fire, R.drawable.ic_heart, R.drawable.ic_star, R.drawable.ic_laugh) + icons.forEachIndexed { index, icon -> + Box( + modifier = Modifier + .size(screenWidthDp(18.dp)) + .clip(RoundedCornerShape(screenWidthDp(6.dp))) + .background(if (states[index]) SpotTheme.colors.B200 else Color.Transparent) + .noRippleClickable { onToggle(index) }, + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(screenWidthDp(14.dp)) + ) + } + } + } +} + +@Composable +fun MemoirSectionItem(label: String, text: String, maxLines: Int, onLineMeasured: (Int) -> Unit) { + if (text.isEmpty() || maxLines <= 0) return + Column(modifier = Modifier.padding(vertical = screenHeightDp(8.dp))) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(screenWidthDp(8.dp)).clip(CircleShape).background(B200)) + Spacer(modifier = Modifier.width(screenWidthDp(6.dp))) + Text(text = label, style = SpotTheme.typography.regular_500, color = SpotTheme.colors.black) + } + Spacer(modifier = Modifier.height(screenHeightDp(4.dp))) + Text( + text = text, + style = SpotTheme.typography.medium_400, + color = SpotTheme.colors.black, + modifier = Modifier.fillMaxWidth(), + maxLines = if (maxLines > 1) maxLines - 1 else 1, + onTextLayout = { onLineMeasured(1 + it.lineCount) } + ) + } +} + +@Composable +fun EmojiBadge(iconRes: Int, count: Int, isSelected: Boolean, onClick: () -> Unit) { + if (count <= 0 && !isSelected) return + val visualIconSize = if (iconRes == R.drawable.ic_laugh) screenWidthDp(18.dp) else screenWidthDp(14.dp) + val backgroundColor = if (isSelected) SpotTheme.colors.B200 else Color.Transparent + + Row( + modifier = Modifier + .clip(RoundedCornerShape(screenWidthDp(6.dp))) + .background(backgroundColor) + .noRippleClickable { onClick() } + .padding(horizontal = screenWidthDp(4.dp), vertical = screenHeightDp(2.dp)), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.size(screenWidthDp(14.dp)), contentAlignment = Alignment.Center) { + Image(painter = painterResource(id = iconRes), contentDescription = null, modifier = Modifier.size(visualIconSize), contentScale = ContentScale.Fit) + } + Spacer(modifier = Modifier.width(screenWidthDp(2.dp))) + Text(text = count.toString(), style = SpotTheme.typography.small_400, color = SpotTheme.colors.B500) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/MemoirImageSection.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/MemoirImageSection.kt new file mode 100644 index 00000000..a355a6e3 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/MemoirImageSection.kt @@ -0,0 +1,97 @@ +package com.umcspot.spot.study.detail.component.memoir + +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.G300 +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 MemoirImageSection( + images: List, + onAddClick: () -> Unit, + onRemoveClick: (Int) -> Unit +) { + val isFull = images.size >= 3 + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .noRippleClickable { if (!isFull) onAddClick() } + .padding(vertical = screenHeightDp(2.dp)), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.camera), + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = if (isFull) SpotTheme.colors.G300 else Color.Unspecified + ) + Spacer(modifier = Modifier.width(screenWidthDp(4.dp))) + Text( + text = "사진 추가 (최대 3장)", + style = SpotTheme.typography.regular_500, + color = if (isFull) SpotTheme.colors.G400 else SpotTheme.colors.black + ) + } + + if (images.isNotEmpty()) { + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(8.dp)), + contentPadding = PaddingValues(end = screenWidthDp(17.dp)) + ) { + itemsIndexed(images) { index, uri -> + Box(modifier = Modifier.size(80.dp)) { + AsyncImage( + model = uri, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Crop + ) + Icon( + painter = painterResource(id = R.drawable.dismiss), + contentDescription = null, + tint = SpotTheme.colors.white, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .noRippleClickable { onRemoveClick(index) } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/MemoirInputField.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/MemoirInputField.kt new file mode 100644 index 00000000..9033e9dd --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/MemoirInputField.kt @@ -0,0 +1,77 @@ +package com.umcspot.spot.study.detail.component.memoir + +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.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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +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 + +@Composable +fun MemoirInputField( + label: String, + value: String, + onValueChange: (String) -> Unit, + placeholder: String +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + Text( + text = label, + style = SpotTheme.typography.medium_500, + color = SpotTheme.colors.black + ) + + Spacer(modifier = Modifier.height(screenHeightDp(7.dp))) + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = screenHeightDp(98.dp)), + textStyle = SpotTheme.typography.medium_500.copy( + color = SpotTheme.colors.black + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) + .border( + width = 1.dp, + color = SpotTheme.colors.G200, + shape = RoundedCornerShape(6.dp) + ) + .padding(vertical = screenHeightDp(7.dp), horizontal = screenWidthDp(10.dp)) + ) { + if (value.isEmpty()) { + Text( + text = placeholder, + style = SpotTheme.typography.medium_500, + color = SpotTheme.colors.default + ) + } + innerTextField() + } + } + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/StudyDetailMemoirItem.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/StudyDetailMemoirItem.kt new file mode 100644 index 00000000..05a6ca7d --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/memoir/StudyDetailMemoirItem.kt @@ -0,0 +1,93 @@ +package com.umcspot.spot.study.detail.component.memoir + +import androidx.compose.foundation.background +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.SpotTheme + +@Composable +fun StudyDetailMemoirItem( + thumbnailUrl: String?, + description: String, + writerName: String, + authorProfileUrl: String?, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .width(96.dp) + .height(175.dp) + .padding(4.dp) + ) { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(88.dp) + .clip(RoundedCornerShape(6.dp)) + .background(SpotTheme.colors.gray100), + contentScale = ContentScale.Crop, + placeholder = painterResource(id = R.drawable.spot_logo), + error = painterResource(id = R.drawable.spot_logo) + ) + + Spacer(modifier = Modifier.height(7.dp)) + + Text( + text = description, + style = SpotTheme.typography.small_400, + color = SpotTheme.colors.black, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .width(88.dp) + .weight(1f) + ) + + Spacer(modifier = Modifier.height(7.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.width(88.dp) + ) { + AsyncImage( + model = authorProfileUrl, + contentDescription = null, + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(SpotTheme.colors.gray100), + contentScale = ContentScale.Crop, + error = painterResource(id = R.drawable.spot_logo) + ) + Text( + text = writerName, + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/planner/StudyDetailScheduleItem.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/planner/StudyDetailScheduleItem.kt new file mode 100644 index 00000000..48980f5a --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/planner/StudyDetailScheduleItem.kt @@ -0,0 +1,106 @@ +package com.umcspot.spot.study.detail.component.planner + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.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.unit.dp +import com.umcspot.spot.designsystem.theme.B100 +import com.umcspot.spot.designsystem.theme.B50 +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.R500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun StudyDetailScheduleItem( + title: String, + timeRange: String, + isNow: Boolean = false, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = SpotTheme.colors.B50, + shape = RoundedCornerShape(10.dp) + ) + .padding( + horizontal = screenWidthDp(12.dp), + vertical = screenHeightDp(8.dp) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(verticalArrangement = Arrangement.spacedBy(screenHeightDp(7.dp))) { + Text( + text = title, + style = SpotTheme.typography.medium_500, + color = SpotTheme.colors.black + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .background( + color = SpotTheme.colors.B100, + shape = RoundedCornerShape(6.dp) + ) + .padding( + horizontal = screenWidthDp(4.dp), + vertical = screenHeightDp(1.dp) + ) + ) { + Text( + text = "일시", + style = SpotTheme.typography.small_500, + color = SpotTheme.colors.B500 + ) + } + + Text( + text = timeRange, + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.gray500 + ) + + Spacer(modifier = Modifier.weight(1f)) + + if (isNow) { + Box( + modifier = Modifier + .background( + color = SpotTheme.colors.R500, + shape = RoundedCornerShape(14.dp) + ) + .padding( + horizontal = screenWidthDp(5.dp), + vertical = screenHeightDp(1.dp) + ) + ) { + Text( + text = "NOW", + style = SpotTheme.typography.small_500, + color = SpotTheme.colors.white + ) + } + } + } + } + + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/component/planner/StudyDetailToDoItem.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/planner/StudyDetailToDoItem.kt new file mode 100644 index 00000000..38916a92 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/component/planner/StudyDetailToDoItem.kt @@ -0,0 +1,155 @@ +package com.umcspot.spot.study.detail.component.planner + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.detail.component.common.DeleteMenuPopup +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun StudyDetailToDoItem( + text: String, + isCompleted: Boolean, + isMyToDo: Boolean, + isEditing: Boolean = false, + onTextChange: (String) -> Unit = {}, + onEnterPressed: () -> Unit = {}, + onCheckedChange: (Boolean) -> Unit = {}, + onDeleteClick: () -> Unit = {} +) { + var showMenu by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val scope = rememberCoroutineScope() + + LaunchedEffect(isEditing) { + if (isEditing) { + delay(100) + focusRequester.requestFocus() + bringIntoViewRequester.bringIntoView() + keyboardController?.show() + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = screenHeightDp(6.dp)) + .bringIntoViewRequester(bringIntoViewRequester), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource( + id = if (isCompleted) R.drawable.ic_todo_check_filled else R.drawable.ic_todo_check_unfilled + ), + contentDescription = null, + modifier = Modifier.noRippleClickable { + if (isMyToDo && !isEditing) onCheckedChange(!isCompleted) + } + ) + + Spacer(modifier = Modifier.width(screenWidthDp(7.dp))) + + if (isEditing) { + BasicTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + scope.launch { + delay(200) + bringIntoViewRequester.bringIntoView() + } + } + }, + textStyle = SpotTheme.typography.regular_500.copy(color = SpotTheme.colors.black), + cursorBrush = SolidColor(SpotTheme.colors.black), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onEnterPressed() }), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (text.isEmpty()) { + Text( + text = "할 일을 입력해주세요", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.gray400 + ) + } + innerTextField() + } + } + ) + } else { + Text( + text = text, + style = SpotTheme.typography.regular_500, + color = if (isCompleted) SpotTheme.colors.gray400 else SpotTheme.colors.black, + modifier = Modifier.weight(1f) + ) + } + + if (isMyToDo && !isEditing) { + Box { + Icon( + painter = painterResource(id = R.drawable.ic_meetball), + contentDescription = null, + modifier = Modifier.noRippleClickable { showMenu = true } + ) + if (showMenu) { + Popup( + alignment = Alignment.TopEnd, + offset = IntOffset(0, 70), + onDismissRequest = { showMenu = false } + ) { + DeleteMenuPopup(onDelete = { + onDeleteClick() + showMenu = false + }) + } + } + } + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/mapper/StudyDetailMapper.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/mapper/StudyDetailMapper.kt new file mode 100644 index 00000000..d678cbe3 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/mapper/StudyDetailMapper.kt @@ -0,0 +1,21 @@ +package com.umcspot.spot.study.detail.mapper + +import com.umcspot.spot.model.StudyTheme +import kotlinx.collections.immutable.ImmutableList +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +fun LocalDateTime.toUiTime(): String { + val formatter = DateTimeFormatter.ofPattern("hh:mma", Locale.ENGLISH) + return this.format(formatter).lowercase() +} + +fun ImmutableList.toCategoryString(): String { + return this.mapNotNull { categoryName -> + StudyTheme.entries.find { it.name == categoryName }?.title + }.joinToString(" / ") +} + +val Int.formatCount: String + get() = if (this >= 1000) "999+" else this.toString() \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyDetailState.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyDetailState.kt new file mode 100644 index 00000000..9efeaa19 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyDetailState.kt @@ -0,0 +1,52 @@ +package com.umcspot.spot.study.detail.model + +import com.umcspot.spot.study.model.MemoirModel +import com.umcspot.spot.study.model.StudyMemberModel +import com.umcspot.spot.study.model.StudyRecentMemoirModel +import com.umcspot.spot.study.model.StudyScheduleModel +import com.umcspot.spot.study.model.TodoModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.time.LocalDate + +data class StudyDetailState( + val homeState: StudyHomeState = StudyHomeState(), + val plannerState: StudyPlannerState = StudyPlannerState(), + val memoirState: StudyMemoirState = StudyMemoirState(), + val isLoading: Boolean = false +) + +data class StudyHomeState( + val studyTitle: String = "", + val studyDescription: String = "", + val thumbnailUrl: String? = null, + val categories: ImmutableList = persistentListOf(), + val currentMembers: Int = 0, + val totalMembers: Int = 0, + val likeCount: Int = 0, + val hitCount: Int = 0, + val members: ImmutableList = persistentListOf(), + val schedules: ImmutableList = persistentListOf(), + val recentMemoirs: ImmutableList = persistentListOf() +) + +data class StudyPlannerState( + val selectedDate: LocalDate = LocalDate.now(), + val monthlySchedules: ImmutableList = persistentListOf(), + val selectedDaySchedules: ImmutableList = persistentListOf(), + val todoList: ImmutableList = persistentListOf(), + val selectedMemberId: String = "" +) + +data class StudyMemoirState( + val memoirs: ImmutableList = persistentListOf(), + val hasNext: Boolean = false, + val nextCursor: Long = 0L, + val totalElements: Int = 0 +) + +sealed interface StudyDetailSideEffect { + data class ShowSnackBar(val message: String) : StudyDetailSideEffect + object MemoirPostSuccess : StudyDetailSideEffect + object ReactionSuccess : StudyDetailSideEffect +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyDetailTab.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyDetailTab.kt new file mode 100644 index 00000000..05ce666e --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyDetailTab.kt @@ -0,0 +1,5 @@ +package com.umcspot.spot.study.detail.model + +enum class StudyDetailTab(val title: String) { + HOME("홈"), PLANNER("플래너"), BOARD("게시판"), MEMOIR("회고록") +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt index 24921af4..35f5e79c 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt @@ -8,26 +8,48 @@ import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.umcspot.spot.navigation.Route import com.umcspot.spot.study.detail.StudyDetailRoute +import com.umcspot.spot.study.detail.model.StudyDetailTab +import com.umcspot.spot.study.detail.screen.StudyMemoirPostRoute import kotlinx.serialization.Serializable fun NavController.navigateToStudyDetail(studyId: Long, navOptions: NavOptions? = null) { navigate(StudyDetail(studyId), navOptions) } +fun NavController.navigateToStudyMemoirPost(studyId: Long, navOptions: NavOptions? = null) { + navigate(StudyMemoirPost(studyId), navOptions) +} + fun NavGraphBuilder.studyDetailGraph( contentPadding: PaddingValues, - onBackClick: () -> Unit + onDetailBackClick: () -> Unit, + onMemoirPostBackClick: () -> Unit, + onTabChanged: (StudyDetailTab) -> Unit, + currentTab: StudyDetailTab ) { composable { backStackEntry -> val detail = backStackEntry.toRoute() - StudyDetailRoute( contentPadding = contentPadding, studyId = detail.studyId, - onBackClick = onBackClick + onBackClick = onDetailBackClick, + onTabChanged = onTabChanged, + initialTab = currentTab + ) + } + + composable { backStackEntry -> + val post = backStackEntry.toRoute() + StudyMemoirPostRoute( + studyId = post.studyId, + contentPadding = contentPadding, + onBackClick = onMemoirPostBackClick ) } } @Serializable -data class StudyDetail(val studyId: Long) : Route \ No newline at end of file +data class StudyDetail(val studyId: Long) : Route + +@Serializable +data class StudyMemoirPost(val studyId: Long) : Route \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailBoardScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailBoardScreen.kt new file mode 100644 index 00000000..e446b68f --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailBoardScreen.kt @@ -0,0 +1,16 @@ +package com.umcspot.spot.study.detail.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.umcspot.spot.designsystem.theme.SpotTheme + +@Composable +fun StudyDetailBoardScreen() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = "게시판 화면입니다.", style = SpotTheme.typography.h5) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailHomeScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailHomeScreen.kt new file mode 100644 index 00000000..cad7097f --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailHomeScreen.kt @@ -0,0 +1,119 @@ +package com.umcspot.spot.study.detail.screen + +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +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.detail.component.memoir.StudyDetailMemoirItem +import com.umcspot.spot.study.detail.component.planner.StudyDetailScheduleItem +import com.umcspot.spot.study.detail.component.common.StudyMemberItem +import com.umcspot.spot.study.detail.mapper.toUiTime +import com.umcspot.spot.study.model.StudyMemberModel +import com.umcspot.spot.study.model.StudyRecentMemoirModel +import com.umcspot.spot.study.model.StudyScheduleModel +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun StudyDetailHomeScreen( + description: String, + members: ImmutableList, + schedules: ImmutableList, + recentMemoirs: ImmutableList +) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, SpotTheme.colors.gray200, RoundedCornerShape(6.dp)) + .padding(horizontal = screenWidthDp(9.dp), vertical = screenHeightDp(7.dp)) + ) { + Text( + text = description, + style = SpotTheme.typography.h5, + color = SpotTheme.colors.black + ) + } + + Spacer(modifier = Modifier.height(screenHeightDp(32.dp))) + + Text(text = "멤버", style = SpotTheme.typography.h4, color = SpotTheme.colors.black) + Spacer(modifier = Modifier.height(screenHeightDp(8.dp))) + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(13.dp)) + ) { + items(items = members, key = { it.id }) { member -> + StudyMemberItem( + name = member.name, + profileUrl = member.profileUrl, + isLeader = member.isLeader + ) + } + } + + Spacer(modifier = Modifier.height(screenHeightDp(32.dp))) + + Text(text = "다가오는 일정", style = SpotTheme.typography.h4, color = SpotTheme.colors.black) + Spacer(modifier = Modifier.height(screenHeightDp(8.dp))) + if (schedules.isEmpty()) { + Text( + text = "일정이 없습니다.", + style = SpotTheme.typography.medium_400, + color = SpotTheme.colors.gray400, + modifier = Modifier.padding(vertical = screenHeightDp(20.dp)) + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(screenHeightDp(16.dp))) { + schedules.forEach { schedule -> + val timeRange = "${schedule.startAt.toUiTime()} - ${schedule.endAt.toUiTime()}" + StudyDetailScheduleItem( + title = schedule.title, + timeRange = timeRange, + isNow = schedule.isNow + ) + } + } + } + + Spacer(modifier = Modifier.height(screenHeightDp(32.dp))) + + Text(text = "최근 회고록", style = SpotTheme.typography.h4, color = SpotTheme.colors.black) + Spacer(modifier = Modifier.height(screenHeightDp(8.dp))) + if (recentMemoirs.isEmpty()) { + Text( + text = "회고록이 없습니다.", + style = SpotTheme.typography.medium_400, + color = SpotTheme.colors.gray400, + modifier = Modifier.padding(vertical = screenHeightDp(20.dp)) + ) + } else { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(8.dp)), + ) { + items(items = recentMemoirs, key = { it.id }) { memoir -> + StudyDetailMemoirItem( + thumbnailUrl = memoir.thumbnailUrl, + description = if (memoir.isPrivate) "이 글은 스터디원에게만 노출됩니다." else memoir.activityContent, + writerName = memoir.writerNickname, + authorProfileUrl = memoir.writerProfileUrl + ) + } + } + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailMemoirScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailMemoirScreen.kt new file mode 100644 index 00000000..1a44e5bf --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailMemoirScreen.kt @@ -0,0 +1,226 @@ +package com.umcspot.spot.study.detail.screen + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +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.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import coil.compose.AsyncImage +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.detail.component.common.DeleteMenuPopup +import com.umcspot.spot.study.detail.component.memoir.EmojiBadge +import com.umcspot.spot.study.detail.component.memoir.EmojiOptionPopup +import com.umcspot.spot.study.detail.component.memoir.MemoirSectionItem +import com.umcspot.spot.study.model.MemoirModel +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun StudyDetailMemoirScreen( + studyId: Long, + memoirs: List, + onEmojiToggle: (Long, String, Boolean) -> Unit, + onDeleteMemoir: (Long) -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + memoirs.forEachIndexed { index, memoir -> + MemoirItemView( + memoir = memoir, + onEmojiClick = { type, isSelected -> + onEmojiToggle(memoir.memoirId, type, isSelected) + }, + onDeleteClick = { onDeleteMemoir(memoir.memoirId) } + ) + if (index < memoirs.lastIndex) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 0.5.dp, + color = SpotTheme.colors.gray300 + ) + } + } + } +} + +@Composable +fun MemoirItemView( + memoir: MemoirModel, + onEmojiClick: (String, Boolean) -> Unit, + onDeleteClick: () -> Unit +) { + var isExpanded by remember { mutableStateOf(false) } + var isEmojiPopupVisible by remember { mutableStateOf(false) } + var isDeleteMenuVisible by remember { mutableStateOf(false) } + + val activityLines = remember { mutableIntStateOf(0) } + val learnedLines = remember { mutableIntStateOf(0) } + val encouragementLines = remember { mutableIntStateOf(0) } + val isOverflowing = (activityLines.intValue + learnedLines.intValue + encouragementLines.intValue) >= 9 + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = screenHeightDp(12.dp)) + .animateContentSize() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(screenWidthDp(33.dp)).clip(CircleShape).background(SpotTheme.colors.gray300)) { + AsyncImage(model = memoir.profileImageUrl, contentDescription = null, contentScale = ContentScale.Crop) + } + Spacer(modifier = Modifier.width(screenWidthDp(7.dp))) + Text(text = memoir.nickname, style = SpotTheme.typography.medium_400, color = SpotTheme.colors.black) + } + + if (memoir.isMyMemoir) { + Box { + Icon( + painter = painterResource(id = R.drawable.ic_meetball), + contentDescription = null, + modifier = Modifier.noRippleClickable { isDeleteMenuVisible = true } + ) + if (isDeleteMenuVisible) { + Popup( + alignment = Alignment.TopEnd, + offset = IntOffset(0, 70), + onDismissRequest = { isDeleteMenuVisible = false } + ) { + DeleteMenuPopup(onDelete = { + isDeleteMenuVisible = false + onDeleteClick() + }) + } + } + } + } + } + + Spacer(modifier = Modifier.height(screenHeightDp(7.dp))) + + Column(modifier = Modifier.fillMaxWidth()) { + MemoirSectionItem("오늘 한 일", memoir.activity, if (!isExpanded && isOverflowing) 8 else Int.MAX_VALUE) { activityLines.intValue = it } + if (isExpanded || activityLines.intValue < 8) { + MemoirSectionItem("새롭게 배운 점", memoir.learned, if (!isExpanded && isOverflowing) (8 - activityLines.intValue) else Int.MAX_VALUE) { learnedLines.intValue = it } + } + if (isExpanded || (activityLines.intValue + learnedLines.intValue) < 8) { + MemoirSectionItem("고생한 나에게 한 마디", memoir.encouragement, if (!isExpanded && isOverflowing) (8 - (activityLines.intValue + learnedLines.intValue)) else Int.MAX_VALUE) { encouragementLines.intValue = it } + } + + if (isOverflowing) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = screenHeightDp(8.dp)).noRippleClickable { isExpanded = !isExpanded }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = if (isExpanded) R.drawable.arrow_up else R.drawable.arrow_down), + contentDescription = null, + modifier = Modifier.size(screenWidthDp(14.dp)), + tint = SpotTheme.colors.black + ) + Spacer(modifier = Modifier.width(screenWidthDp(4.dp))) + Text(text = if (isExpanded) "간략히" else "더보기", style = SpotTheme.typography.small_400, color = SpotTheme.colors.black) + } + } + + if (!memoir.imageUrl.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) + LazyRow(horizontalArrangement = Arrangement.spacedBy(screenWidthDp(8.dp))) { + items(listOf(memoir.imageUrl)) { url -> + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier.size(screenWidthDp(140.dp)).clip(RoundedCornerShape(screenWidthDp(6.dp))).background(SpotTheme.colors.gray100), + contentScale = ContentScale.Crop + ) + } + } + } + } + + Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box { + Icon( + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = null, + modifier = Modifier.size(screenWidthDp(20.dp)).noRippleClickable { isEmojiPopupVisible = true }, + tint = SpotTheme.colors.black + ) + + if (isEmojiPopupVisible) { + Popup( + alignment = Alignment.BottomStart, + offset = IntOffset(0, 85), + onDismissRequest = { isEmojiPopupVisible = false } + ) { + EmojiOptionPopup( + states = listOf(memoir.reactions.isFired, memoir.reactions.isHearted, memoir.reactions.isStarred, memoir.reactions.isSmiled), + onToggle = { index -> + val (type, isSelected) = when (index) { + 0 -> "FIRE" to memoir.reactions.isFired + 1 -> "HEART" to memoir.reactions.isHearted + 2 -> "STAR" to memoir.reactions.isStarred + else -> "SMILE" to memoir.reactions.isSmiled + } + onEmojiClick(type, isSelected) + isEmojiPopupVisible = false + } + ) + } + } + } + + Spacer(modifier = Modifier.width(screenWidthDp(5.dp))) + EmojiBadge(R.drawable.ic_fire, memoir.reactionCounts.fireCount, memoir.reactions.isFired) { onEmojiClick("FIRE", memoir.reactions.isFired) } + Spacer(modifier = Modifier.width(screenWidthDp(5.dp))) + EmojiBadge(R.drawable.ic_heart, memoir.reactionCounts.heartCount, memoir.reactions.isHearted) { onEmojiClick("HEART", memoir.reactions.isHearted) } + Spacer(modifier = Modifier.width(screenWidthDp(5.dp))) + EmojiBadge(R.drawable.ic_star, memoir.reactionCounts.starCount, memoir.reactions.isStarred) { onEmojiClick("STAR", memoir.reactions.isStarred) } + Spacer(modifier = Modifier.width(screenWidthDp(5.dp))) + EmojiBadge(R.drawable.ic_laugh, memoir.reactionCounts.smileCount, memoir.reactions.isSmiled) { onEmojiClick("SMILE", memoir.reactions.isSmiled) } + } + + Text(text = memoir.createdAt, style = SpotTheme.typography.regular_400, color = SpotTheme.colors.gray400) + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailPlannerScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailPlannerScreen.kt new file mode 100644 index 00000000..1b298bf6 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailPlannerScreen.kt @@ -0,0 +1,215 @@ +package com.umcspot.spot.study.detail.screen + +import androidx.compose.animation.animateContentSize +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +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.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.kizitonwose.calendar.compose.rememberCalendarState +import com.kizitonwose.calendar.core.daysOfWeek +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.SpotPlannerCalendar +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.detail.component.common.StudyDetailCreateButton +import com.umcspot.spot.study.detail.component.planner.StudyDetailScheduleItem +import com.umcspot.spot.study.detail.component.planner.StudyDetailToDoItem +import com.umcspot.spot.study.detail.component.common.StudyMemberItem +import com.umcspot.spot.study.detail.mapper.toUiTime +import com.umcspot.spot.study.detail.model.StudyPlannerState +import com.umcspot.spot.study.model.StudyMemberModel +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.ImmutableList +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.temporal.WeekFields + +@Composable +fun StudyDetailPlannerScreen( + studyId: Long, + plannerState: StudyPlannerState, + members: ImmutableList, + onDateSelected: (LocalDate) -> Unit, + onMonthChanged: (Int, Int) -> Unit, + onAddingTodo: () -> Unit, + onTodoCreate: (Long, String) -> Unit, + onTodoToggle: (Long, Long, Boolean) -> Unit, + onTodoDelete: (Long, Long) -> Unit, + onMemberSelected: (Long) -> Unit +) { + var isExpanded by remember { mutableStateOf(false) } + var isAddingTodo by remember { mutableStateOf(false) } + var newTodoText by remember { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current + + val daysOfWeekList = remember { daysOfWeek(firstDayOfWeek = DayOfWeek.MONDAY) } + val monthState = rememberCalendarState( + startMonth = YearMonth.now().minusMonths(12), + endMonth = YearMonth.now().plusMonths(12), + firstVisibleMonth = YearMonth.from(plannerState.selectedDate), + firstDayOfWeek = daysOfWeekList.first() + ) + + val isAvailableDate = remember(plannerState.selectedDate) { + val today = LocalDate.now() + !plannerState.selectedDate.isBefore(today) + } + + LaunchedEffect(monthState.firstVisibleMonth) { + val ym = monthState.firstVisibleMonth.yearMonth + onMonthChanged(ym.year, ym.monthValue) + } + + val monthTitle = plannerState.selectedDate.format(DateTimeFormatter.ofPattern("yyyy년 M월")) + val weekNumber = plannerState.selectedDate.get(WeekFields.of(DayOfWeek.MONDAY, 1).weekOfMonth()) + val weekTitle = "$monthTitle ${weekNumber}주차" + + Column(modifier = Modifier + .fillMaxWidth() + .animateContentSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = screenWidthDp(13.dp)), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = if (isExpanded) monthTitle else weekTitle, style = SpotTheme.typography.h4) + Icon( + painter = painterResource(id = if (isExpanded) R.drawable.arrow_up else R.drawable.arrow_down), + contentDescription = null, + tint = B500, + modifier = Modifier + .size(screenWidthDp(14.dp)) + .noRippleClickable { isExpanded = !isExpanded } + ) + } + + Spacer(modifier = Modifier.height(screenHeightDp(6.dp))) + + SpotPlannerCalendar( + isExpanded = isExpanded, + monthState = monthState, + selectedDate = plannerState.selectedDate, + daysOfWeek = daysOfWeekList, + onDateSelected = onDateSelected + ) + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + plannerState.selectedDaySchedules.forEach { schedule -> + StudyDetailScheduleItem( + title = schedule.title, + timeRange = "${schedule.startAt.toUiTime()} - ${schedule.endAt.toUiTime()}", + isNow = schedule.isNow + ) + Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) + } + + HorizontalDivider(thickness = 0.5.dp, color = SpotTheme.colors.gray300) + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + StudyDetailCreateButton( + text = "Todo", + isStudyMember = true, + enabled = isAvailableDate, + onButtonClick = { + if (isAvailableDate) { + onAddingTodo() + isAddingTodo = true + } + } + ) + + Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(13.dp)) + ) { + items(members) { member -> + StudyMemberItem( + name = member.name, + profileUrl = member.profileUrl, + isSelected = plannerState.selectedMemberId == member.id.toString(), + onClick = { onMemberSelected(member.id) } + ) + } + } + + Spacer(modifier = Modifier.height(screenHeightDp(8.dp))) + + // Todo 리스트 + if (isAddingTodo) { + StudyDetailToDoItem( + text = newTodoText, + isCompleted = false, + isMyToDo = true, + isEditing = true, + onTextChange = { newTodoText = it }, + onEnterPressed = { + if (newTodoText.isNotBlank()) { + onTodoCreate(studyId, newTodoText) + newTodoText = "" + isAddingTodo = false + keyboardController?.hide() + } + }, + onDeleteClick = { isAddingTodo = false } + ) + } + + val filteredTodos = + plannerState.todoList.filter { it.memberId == plannerState.selectedMemberId } + + if (filteredTodos.isEmpty() && !isAddingTodo) { + Text( + text = "아직 할 일이 작성되지 않았어요.", + style = SpotTheme.typography.regular_400, + color = SpotTheme.colors.gray400, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = screenHeightDp(40.dp)), + textAlign = TextAlign.Center + ) + } else { + filteredTodos.forEach { todo -> + StudyDetailToDoItem( + text = todo.content, + isCompleted = todo.isCompleted, + isMyToDo = true, + onCheckedChange = { + onTodoToggle(studyId, todo.id, todo.isCompleted) + }, + onDeleteClick = { onTodoDelete(studyId, todo.id) } + ) + } + } + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyMemoirPostScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyMemoirPostScreen.kt new file mode 100644 index 00000000..3b700b26 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyMemoirPostScreen.kt @@ -0,0 +1,210 @@ +package com.umcspot.spot.study.detail.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.appBar.BackTopBar +import com.umcspot.spot.designsystem.component.button.SpotActivationButton +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.G300 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.detail.StudyDetailViewModel +import com.umcspot.spot.study.detail.component.memoir.MemoirImageSection +import com.umcspot.spot.study.detail.component.memoir.MemoirInputField +import com.umcspot.spot.study.detail.model.StudyDetailSideEffect +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import com.umcspot.spot.ui.extension.toFile +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Composable +fun StudyMemoirPostRoute( + studyId: Long, + onBackClick: () -> Unit, + contentPadding: PaddingValues, + viewModel: StudyDetailViewModel = hiltViewModel() +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { effect -> + if (effect is StudyDetailSideEffect.MemoirPostSuccess) { + onBackClick() + } + } + } + + StudyMemoirPostScreen( + studyId = studyId, + isLoading = uiState.isLoading, + contentPadding = contentPadding, + onBackClick = onBackClick, + onPostSubmit = { activity, learned, encouragement, isPrivate, images -> + val imageFiles = images.mapNotNull { it.toFile(context) } + viewModel.postMemoir( + studyId = studyId, + activity = activity, + learned = learned, + encouragement = encouragement, + isPrivate = isPrivate, + imageFiles = imageFiles + ) + } + ) +} +@Composable +fun StudyMemoirPostScreen( + studyId: Long, + isLoading: Boolean, + contentPadding: PaddingValues, + onBackClick: () -> Unit, + onPostSubmit: (String, String, String, Boolean, List) -> Unit +) { + var selectedImages by remember { mutableStateOf(persistentListOf()) } + var content1 by remember { mutableStateOf("") } + var content2 by remember { mutableStateOf("") } + var content3 by remember { mutableStateOf("") } + var isPrivate by remember { mutableStateOf(false) } + + val scrollState = rememberScrollState() + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris -> + val remainingSlot = 3 - selectedImages.size + if (remainingSlot > 0) { + selectedImages = (selectedImages + uris.take(remainingSlot)).toPersistentList() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + .imePadding() + ) { + Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) + + BackTopBar( + title = "글쓰기", + onBackClick = onBackClick + ) + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(scrollState) + .padding(horizontal = screenWidthDp(17.dp)) + ) { + Spacer(modifier = Modifier.height(screenHeightDp(24.dp))) + + MemoirImageSection( + images = selectedImages, + onAddClick = { launcher.launch("image/*") }, + onRemoveClick = { index -> selectedImages = selectedImages.removeAt(index) } + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + MemoirInputField( + label = "오늘은 무엇을 했나요? (필수)", + value = content1, + onValueChange = { content1 = it }, + placeholder = "내용을 입력해주세요." + ) + + MemoirInputField( + label = "오늘 새롭게 배운 점은 무엇인가요?", + value = content2, + onValueChange = { content2 = it }, + placeholder = "내용을 입력해주세요." + ) + + MemoirInputField( + label = "고생한 나에게 한마디!", + value = content3, + onValueChange = { content3 = it }, + placeholder = "내용을 입력해주세요." + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = screenWidthDp(17.dp)) + .padding( + top = screenHeightDp(4.dp), + bottom = if (contentPadding.calculateBottomPadding() > 0.dp) { + contentPadding.calculateBottomPadding() + } else { + screenHeightDp(13.dp) + } + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .noRippleClickable { isPrivate = !isPrivate } + .padding(vertical = screenHeightDp(2.dp)) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check), + contentDescription = null, + modifier = Modifier.size(11.dp), + tint = if (isPrivate) SpotTheme.colors.B500 else SpotTheme.colors.G300 + ) + Spacer(modifier = Modifier.width(screenWidthDp(4.dp))) + Text( + text = "클릭하면, 이 글은 스터디원에게만 노출됩니다.", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black + ) + } + + Spacer(modifier = Modifier.height(screenHeightDp(13.dp))) + + SpotActivationButton( + modifier = Modifier.fillMaxWidth(), + buttonText = "완료", + isEnabled = content1.isNotBlank() && !isLoading, + onClick = { + onPostSubmit(content1, content2, content3, isPrivate, selectedImages) + } + ) + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/my/MyStudyScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/my/MyStudyScreen.kt index 41e25e41..e87c83eb 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/my/MyStudyScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/my/MyStudyScreen.kt @@ -1,27 +1,109 @@ package com.umcspot.spot.study.my 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.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +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.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.SpotSpinner +import com.umcspot.spot.designsystem.component.empty.EmptyAlert +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.my.component.MyStudyListContent +import com.umcspot.spot.study.my.model.MyStudyState @Composable -fun MyStudyScreen() { +fun MyStudyRoute( + contentPadding: PaddingValues, + navigateToStudyDetail: (Long) -> Unit, + viewModel: MyStudyViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + val shouldLoadMore = remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + totalItems > 0 && lastVisibleItemIndex >= totalItems - 3 + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value && state.hasNext && !state.isLoading) { + viewModel.loadMoreStudies() + } + } + Column( modifier = Modifier .fillMaxSize() - .background(Color.White), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .background(SpotTheme.colors.white) ) { - Text( - text = "MyStudy Screen", - color = Color.Black + Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) + + MyStudyScreen( + state = state, + listState = listState, + onItemClick = navigateToStudyDetail, + modifier = Modifier.padding(bottom = contentPadding.calculateBottomPadding()) ) } +} + +@Composable +private fun MyStudyScreen( + state: MyStudyState, + listState: LazyListState, + onItemClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize().background(SpotTheme.colors.white) + ) { + when { + state.isLoading && state.studyList.isEmpty() -> { + SpotSpinner(modifier = Modifier.align(Alignment.Center)) + } + + (state.isError || state.studyList.isEmpty()) && !state.isLoading -> { + EmptyAlert( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(R.drawable.ic_write), + alertTitle = "내 스터디가 없어요.", + alertDes = "관심 있는 스터디에 가입해보세요.", + content = {} + ) + } + + else -> { + MyStudyListContent( + studies = state.studyList, + listState = listState, + onItemClick = onItemClick + ) + + if (state.isLoading && state.studyList.isNotEmpty()) { + SpotSpinner(modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp)) + } + } + } + } } \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/my/MyStudyViewModel.kt b/feature/study/src/main/java/com/umcspot/spot/study/my/MyStudyViewModel.kt new file mode 100644 index 00000000..599dcafb --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/my/MyStudyViewModel.kt @@ -0,0 +1,54 @@ +package com.umcspot.spot.study.my + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.study.my.model.MyStudyState +import com.umcspot.spot.study.repository.StudyRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MyStudyViewModel @Inject constructor( + private val repository: StudyRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(MyStudyState()) + val uiState = _uiState.asStateFlow() + + init { + loadMoreStudies() + } + + fun loadMoreStudies() { + if (_uiState.value.isLoading || (_uiState.value.nextCursor != null && !_uiState.value.hasNext)) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + repository.getParticipatingStudy(_uiState.value.nextCursor, 10) + .onSuccess { resultList -> + _uiState.update { state -> + state.copy( + studyList = (state.studyList + resultList.studyList).toImmutableList(), + hasNext = resultList.hasNext, + nextCursor = resultList.nextCursor, + isLoading = false, + isError = false + ) + } + } + .onFailure { t -> + _uiState.update { it.copy( + isLoading = false, + isError = true, + errorMessage = t.message + ) } + } + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/my/component/MyStudyListContent.kt b/feature/study/src/main/java/com/umcspot/spot/study/my/component/MyStudyListContent.kt new file mode 100644 index 00000000..968de461 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/my/component/MyStudyListContent.kt @@ -0,0 +1,56 @@ +package com.umcspot.spot.study.my.component + +import androidx.compose.foundation.layout.Arrangement +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.component.study.StudyListItem +import com.umcspot.spot.designsystem.theme.G300 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.model.StudyResult +import com.umcspot.spot.ui.extension.screenHeightDp + +@Composable +fun MyStudyListContent( + studies: List, + listState: LazyListState, + onItemClick: (Long) -> Unit +) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + items( + items = studies, + key = { it.id } + ) { item -> + Spacer(Modifier.height(screenHeightDp(5.dp))) + + StudyListItem( + item = item, + modifier = Modifier.fillMaxWidth(), + onClick = { onItemClick(item.id) } + ) + + if (studies.indexOf(item) != studies.lastIndex) { + Spacer(Modifier.height(screenHeightDp(5.dp))) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = SpotTheme.colors.G300, + thickness = 1.dp + ) + } else { + Spacer(Modifier.height(screenHeightDp(5.dp))) + } + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/my/model/MyStudyState.kt b/feature/study/src/main/java/com/umcspot/spot/study/my/model/MyStudyState.kt new file mode 100644 index 00000000..524089c7 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/my/model/MyStudyState.kt @@ -0,0 +1,19 @@ +package com.umcspot.spot.study.my.model + +import com.umcspot.spot.study.model.StudyResult +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class MyStudyState( + val studyList: ImmutableList = persistentListOf(), + val isLoading: Boolean = false, + val hasNext: Boolean = false, + val nextCursor: Long? = null, + val isError: Boolean = false, + val errorMessage: String? = null +) + +sealed interface MyStudySideEffect { + data class ShowSnackBar(val message: String) : MyStudySideEffect + data class NavigateToDetail(val studyId: Long) : MyStudySideEffect +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/my/navigation/MyStudyNavigation.kt b/feature/study/src/main/java/com/umcspot/spot/study/my/navigation/MyStudyNavigation.kt index a52a85ef..93ab1cd1 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/my/navigation/MyStudyNavigation.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/my/navigation/MyStudyNavigation.kt @@ -1,20 +1,27 @@ package com.umcspot.spot.study.my.navigation +import androidx.compose.foundation.layout.PaddingValues import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable -import com.umcspot.spot.study.my.MyStudyScreen import com.umcspot.spot.navigation.MainTabRoute +import com.umcspot.spot.study.my.MyStudyRoute import kotlinx.serialization.Serializable fun NavController.navigateToMyStudy(navOptions: NavOptions? = null) { navigate(MyStudy, navOptions) } -fun NavGraphBuilder.myStudyGraph() { +fun NavGraphBuilder.myStudyGraph( + contentPadding: PaddingValues, + navigateToStudyDetail: (Long) -> Unit, +) { composable { - MyStudyScreen() + MyStudyRoute( + contentPadding = contentPadding, + navigateToStudyDetail = navigateToStudyDetail + ) } }