diff --git a/core/designsystem/src/main/res/drawable/ic_chevron_left.xml b/core/designsystem/src/main/res/drawable/ic_chevron_left.xml new file mode 100644 index 00000000..e6bb3ca9 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_chevron_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_chevron_right.xml b/core/designsystem/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 00000000..24835127 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/values/colors.xml b/core/designsystem/src/main/res/values/colors.xml index f8c6127d..6e373029 100644 --- a/core/designsystem/src/main/res/values/colors.xml +++ b/core/designsystem/src/main/res/values/colors.xml @@ -7,4 +7,8 @@ #FF018786 #FF000000 #FFFFFFFF - \ No newline at end of file + + #303F9F + #FCCA3E + #BEBEBE + diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index e7af56c5..3af6c6ee 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -3,4 +3,7 @@ 네트워크 연결이 불안해요.\n잠시후 다시 이용해주세요. 이용에 불편을 드려 죄송합니다.\n잠시후 다시 이용해주세요. 알 수 없는 오류가 발생하였습니다. + + + Week mode diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 56ba2c16..fe4c9cf0 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -14,5 +14,6 @@ dependencies { libs.kotlinx.collections.immutable, libs.androidx.core, libs.timber, + libs.calendar.compose, ) } diff --git a/feature/home/src/main/kotlin/com/unifest/android/feature/home/Calendar.kt b/feature/home/src/main/kotlin/com/unifest/android/feature/home/Calendar.kt new file mode 100644 index 00000000..fbeb2ca1 --- /dev/null +++ b/feature/home/src/main/kotlin/com/unifest/android/feature/home/Calendar.kt @@ -0,0 +1,358 @@ +package com.unifest.android.feature.home + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +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.shape.CircleShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +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.sp +import com.kizitonwose.calendar.compose.CalendarState +import com.kizitonwose.calendar.compose.HorizontalCalendar +import com.kizitonwose.calendar.compose.WeekCalendar +import com.kizitonwose.calendar.compose.rememberCalendarState +import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState +import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.WeekDayPosition +import com.kizitonwose.calendar.core.atStartOfMonth +import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.core.nextMonth +import com.kizitonwose.calendar.core.previousMonth +import com.kizitonwose.calendar.core.yearMonth +import com.unifest.android.core.designsystem.R +import com.unifest.android.core.designsystem.theme.UnifestTheme +import kotlinx.coroutines.launch +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth + +@Composable +fun Calendar(adjacentMonths: Long = 500) { + val currentDate = remember { LocalDate.now() } + val currentMonth = remember(currentDate) { currentDate.yearMonth } + val startMonth = remember(currentDate) { currentMonth.minusMonths(adjacentMonths) } + val endMonth = remember(currentDate) { currentMonth.plusMonths(adjacentMonths) } + val selections = remember { mutableStateListOf() } + val daysOfWeek = remember { daysOfWeek() } + + var isWeekMode by remember { mutableStateOf(false) } + var isAnimating by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + ) { + val monthState = rememberCalendarState( + startMonth = startMonth, + endMonth = endMonth, + firstVisibleMonth = currentMonth, + firstDayOfWeek = daysOfWeek.first(), + ) + val weekState = rememberWeekCalendarState( + startDate = startMonth.atStartOfMonth(), + endDate = endMonth.atEndOfMonth(), + firstVisibleWeekDate = currentDate, + firstDayOfWeek = daysOfWeek.first(), + ) + CalendarTitle( + isWeekMode = isWeekMode, + monthState = monthState, + weekState = weekState, + isAnimating = isAnimating, + ) + CalendarHeader(daysOfWeek = daysOfWeek) + AnimatedVisibility(visible = !isWeekMode) { + HorizontalCalendar( + state = monthState, + dayContent = { day -> + val isSelectable = day.position == DayPosition.MonthDate + Day( + day.date, + isSelected = isSelectable && selections.contains(day.date), + isSelectable = isSelectable, + ) { clicked -> + if (selections.contains(clicked)) { + selections.remove(clicked) + } else { + selections.add(clicked) + } + } + }, + ) + } + AnimatedVisibility(visible = isWeekMode) { + WeekCalendar( + state = weekState, + dayContent = { day -> + val isSelectable = day.position == WeekDayPosition.RangeDate + Day( + day.date, + isSelected = isSelectable && selections.contains(day.date), + isSelectable = isSelectable, + ) { clicked -> + if (selections.contains(clicked)) { + selections.remove(clicked) + } else { + selections.add(clicked) + } + } + }, + ) + } + Spacer(modifier = Modifier.weight(1f)) + WeekModeToggle( + modifier = Modifier.align(Alignment.CenterHorizontally), + isWeekMode = isWeekMode, + ) { weekMode -> + isAnimating = true + isWeekMode = weekMode + coroutineScope.launch { + if (weekMode) { + val targetDate = monthState.firstVisibleMonth.weekDays.last().last().date + weekState.scrollToWeek(targetDate) + weekState.animateScrollToWeek(targetDate) // Trigger a layout pass for title update + } else { + val targetMonth = weekState.firstVisibleWeek.days.first().date.yearMonth + monthState.scrollToMonth(targetMonth) + monthState.animateScrollToMonth(targetMonth) // Trigger a layout pass for title update + } + isAnimating = false + } + } + } +} + +@Composable +fun MonthAndWeekCalendarTitle( + isWeekMode: Boolean, + currentMonth: YearMonth, + monthState: CalendarState, + weekState: WeekCalendarState, +) { + val coroutineScope = rememberCoroutineScope() + SimpleCalendarTitle( + modifier = Modifier.padding(vertical = 10.dp, horizontal = 8.dp), + currentMonth = currentMonth, + goToPrevious = { + coroutineScope.launch { + if (isWeekMode) { + val targetDate = weekState.firstVisibleWeek.days.first().date.minusDays(1) + weekState.animateScrollToWeek(targetDate) + } else { + val targetMonth = monthState.firstVisibleMonth.yearMonth.previousMonth + monthState.animateScrollToMonth(targetMonth) + } + } + }, + goToNext = { + coroutineScope.launch { + if (isWeekMode) { + val targetDate = weekState.firstVisibleWeek.days.last().date.plusDays(1) + weekState.animateScrollToWeek(targetDate) + } else { + val targetMonth = monthState.firstVisibleMonth.yearMonth.nextMonth + monthState.animateScrollToMonth(targetMonth) + } + } + }, + ) +} + +@Composable +fun SimpleCalendarTitle( + modifier: Modifier, + currentMonth: YearMonth, + goToPrevious: () -> Unit, + goToNext: () -> Unit, +) { + Row( + modifier = modifier.height(40.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CalendarNavigationIcon( + icon = painterResource(id = R.drawable.ic_chevron_left), + contentDescription = "Previous", + onClick = goToPrevious, + ) + Text( + modifier = Modifier.weight(1f), + text = currentMonth.displayText(), + fontSize = 22.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Medium, + ) + CalendarNavigationIcon( + icon = painterResource(id = R.drawable.ic_chevron_right), + contentDescription = "Next", + onClick = goToNext, + ) + } +} + +@Composable +private fun CalendarNavigationIcon( + icon: Painter, + contentDescription: String, + onClick: () -> Unit, +) = Box( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f) + .clip(shape = CircleShape) + .clickable(role = Role.Button, onClick = onClick), +) { + Icon( + modifier = Modifier + .fillMaxSize() + .padding(4.dp) + .align(Alignment.Center), + painter = icon, + contentDescription = contentDescription, + ) +} + +@Composable +private fun CalendarTitle( + isWeekMode: Boolean, + monthState: CalendarState, + weekState: WeekCalendarState, + isAnimating: Boolean, +) { + val visibleMonth = rememberFirstVisibleMonthAfterScroll(monthState) + val visibleWeek = rememberFirstVisibleWeekAfterScroll(weekState) + val visibleWeekMonth = visibleWeek.days.first().date.yearMonth + // Track animation state to prevent updating the title too early before + // the correct value is available (after the animation). + val currentMonth = if (isWeekMode) { + if (isAnimating) visibleMonth.yearMonth else visibleWeekMonth + } else { + if (isAnimating) visibleWeekMonth else visibleMonth.yearMonth + } + MonthAndWeekCalendarTitle( + isWeekMode = isWeekMode, + currentMonth = currentMonth, + monthState = monthState, + weekState = weekState, + ) +} + +@Composable +fun CalendarHeader(daysOfWeek: List) { + Row( + modifier = Modifier + .fillMaxWidth(), + ) { + for (dayOfWeek in daysOfWeek) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 15.sp, + text = dayOfWeek.displayText(), + fontWeight = FontWeight.Medium, + ) + } + } +} + +@Composable +fun Day( + day: LocalDate, + isSelected: Boolean, + isSelectable: Boolean, + onClick: (LocalDate) -> Unit, +) { + Box( + modifier = Modifier + .aspectRatio(1f) // This is important for square-sizing! + .padding(6.dp) + .clip(CircleShape) + .background(color = if (isSelected) colorResource(R.color.example_1_selection_color) else Color.Transparent) + .clickable( + enabled = isSelectable, + showRipple = !isSelected, + onClick = { onClick(day) }, + ), + contentAlignment = Alignment.Center, + ) { + val textColor = when { + isSelected -> Color.White + isSelectable -> Color.Unspecified + else -> colorResource(R.color.inactive_text_color) + } + Text( + text = day.dayOfMonth.toString(), + color = textColor, + fontSize = 14.sp, + ) + } +} + +@Composable +fun WeekModeToggle( + modifier: Modifier, + isWeekMode: Boolean, + weekModeToggled: (isWeekMode: Boolean) -> Unit, +) { + // We want the entire content to be clickable, not just the checkbox. + Row( + modifier = modifier + .padding(10.dp) + .clip(MaterialTheme.shapes.small) + .clickable { weekModeToggled(!isWeekMode) } + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Checkbox( + checked = isWeekMode, + onCheckedChange = null, // Check is handled by parent. + colors = CheckboxDefaults.colors(checkedColor = colorResource(R.color.example_1_selection_color)), + ) + Text(text = stringResource(R.string.week_mode)) + } +} + +@Preview +@Composable +private fun CalendarPreview() { + UnifestTheme { + Calendar() + } +} diff --git a/feature/home/src/main/kotlin/com/unifest/android/feature/home/HomeScreen.kt b/feature/home/src/main/kotlin/com/unifest/android/feature/home/HomeScreen.kt index 66934ec2..c9260d54 100644 --- a/feature/home/src/main/kotlin/com/unifest/android/feature/home/HomeScreen.kt +++ b/feature/home/src/main/kotlin/com/unifest/android/feature/home/HomeScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,7 +30,7 @@ internal fun HomeScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text("Home Screen") + Calendar() } } diff --git a/feature/home/src/main/kotlin/com/unifest/android/feature/home/Utils.kt b/feature/home/src/main/kotlin/com/unifest/android/feature/home/Utils.kt new file mode 100644 index 00000000..d9bd5582 --- /dev/null +++ b/feature/home/src/main/kotlin/com/unifest/android/feature/home/Utils.kt @@ -0,0 +1,161 @@ +package com.unifest.android.feature.home + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.semantics.Role +import com.kizitonwose.calendar.compose.CalendarLayoutInfo +import com.kizitonwose.calendar.compose.CalendarState +import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState +import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.Week +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import java.time.DayOfWeek +import java.time.Month +import java.time.YearMonth +import java.time.format.TextStyle +import java.util.Locale + +fun Modifier.clickable( + enabled: Boolean = true, + showRipple: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit, +): Modifier = composed { + clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = if (showRipple) LocalIndication.current else null, + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = onClick, + ) +} + +/** + * Alternative way to find the first fully visible month in the layout. + * + * @see [rememberFirstVisibleMonthAfterScroll] + * @see [rememberFirstMostVisibleMonth] + */ +@Composable +fun rememberFirstCompletelyVisibleMonth(state: CalendarState): CalendarMonth { + val visibleMonth = remember(state) { mutableStateOf(state.firstVisibleMonth) } + // Only take non-null values as null will be produced when the + // list is mid-scroll as no index will be completely visible. + LaunchedEffect(state) { + snapshotFlow { state.layoutInfo.completelyVisibleMonths.firstOrNull() } + .filterNotNull() + .collect { month -> visibleMonth.value = month } + } + return visibleMonth.value +} + +/** + * Returns the first visible month in a paged calendar **after** scrolling stops. + * + * @see [rememberFirstCompletelyVisibleMonth] + * @see [rememberFirstMostVisibleMonth] + */ +@Composable +fun rememberFirstVisibleMonthAfterScroll(state: CalendarState): CalendarMonth { + val visibleMonth = remember(state) { mutableStateOf(state.firstVisibleMonth) } + LaunchedEffect(state) { + snapshotFlow { state.isScrollInProgress } + .filter { scrolling -> !scrolling } + .collect { visibleMonth.value = state.firstVisibleMonth } + } + return visibleMonth.value +} + +/** + * Find first visible week in a paged week calendar **after** scrolling stops. + */ +@Composable +fun rememberFirstVisibleWeekAfterScroll(state: WeekCalendarState): Week { + val visibleWeek = remember(state) { mutableStateOf(state.firstVisibleWeek) } + LaunchedEffect(state) { + snapshotFlow { state.isScrollInProgress } + .filter { scrolling -> !scrolling } + .collect { visibleWeek.value = state.firstVisibleWeek } + } + return visibleWeek.value +} + +/** + * Find the first month on the calendar visible up to the given [viewportPercent] size. + * + * @see [rememberFirstCompletelyVisibleMonth] + * @see [rememberFirstVisibleMonthAfterScroll] + */ +@Composable +fun rememberFirstMostVisibleMonth( + state: CalendarState, + viewportPercent: Float = 50f, +): CalendarMonth { + val visibleMonth = remember(state) { mutableStateOf(state.firstVisibleMonth) } + LaunchedEffect(state) { + snapshotFlow { state.layoutInfo.firstMostVisibleMonth(viewportPercent) } + .filterNotNull() + .collect { month -> visibleMonth.value = month } + } + return visibleMonth.value +} + +private val CalendarLayoutInfo.completelyVisibleMonths: List + get() { + val visibleItemsInfo = this.visibleMonthsInfo.toMutableList() + return if (visibleItemsInfo.isEmpty()) { + emptyList() + } else { + val lastItem = visibleItemsInfo.last() + val viewportSize = this.viewportEndOffset + this.viewportStartOffset + if (lastItem.offset + lastItem.size > viewportSize) { + visibleItemsInfo.removeLast() + } + val firstItem = visibleItemsInfo.firstOrNull() + if (firstItem != null && firstItem.offset < this.viewportStartOffset) { + visibleItemsInfo.removeFirst() + } + visibleItemsInfo.map { it.month } + } + } + +private fun CalendarLayoutInfo.firstMostVisibleMonth(viewportPercent: Float = 50f): CalendarMonth? { + return if (visibleMonthsInfo.isEmpty()) { + null + } else { + val viewportSize = (viewportEndOffset + viewportStartOffset) * viewportPercent / 100f + visibleMonthsInfo.firstOrNull { itemInfo -> + if (itemInfo.offset < 0) { + itemInfo.offset + itemInfo.size >= viewportSize + } else { + itemInfo.size - itemInfo.offset >= viewportSize + } + }?.month + } +} + +fun YearMonth.displayText(short: Boolean = false): String { + return "${this.year} ${this.month.displayText(short = short)}" +} + +fun Month.displayText(short: Boolean = true): String { + val style = if (short) TextStyle.SHORT else TextStyle.FULL + return getDisplayName(style, Locale.KOREA) +} + +fun DayOfWeek.displayText(uppercase: Boolean = false): String { + return getDisplayName(TextStyle.SHORT, Locale.KOREA).let { value -> + if (uppercase) value.uppercase(Locale.KOREA) else value + } +} diff --git a/feature/main/src/main/kotlin/com/unifest/android/feature/main/MainActivity.kt b/feature/main/src/main/kotlin/com/unifest/android/feature/main/MainActivity.kt index 7e194933..36a9aaa3 100644 --- a/feature/main/src/main/kotlin/com/unifest/android/feature/main/MainActivity.kt +++ b/feature/main/src/main/kotlin/com/unifest/android/feature/main/MainActivity.kt @@ -3,14 +3,12 @@ package com.unifest.android.feature.main import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge import com.unifest.android.core.designsystem.theme.UnifestTheme import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { val navigator: MainNavController = rememberMainNavController() diff --git a/feature/main/src/main/kotlin/com/unifest/android/feature/main/MainScreen.kt b/feature/main/src/main/kotlin/com/unifest/android/feature/main/MainScreen.kt index 053bb332..7c99271d 100644 --- a/feature/main/src/main/kotlin/com/unifest/android/feature/main/MainScreen.kt +++ b/feature/main/src/main/kotlin/com/unifest/android/feature/main/MainScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.selection.selectable import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -107,7 +106,7 @@ internal fun MainScreen( ) }, snackbarHost = { SnackbarHost(snackBarHostState) }, - containerColor = MaterialTheme.colorScheme.background, + containerColor = Color.White, ) } @@ -119,7 +118,7 @@ private fun MainBottomBar( onTabSelected: (MainTab) -> Unit, ) { if (visible) { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { + Box(modifier = Modifier.background(Color.White)) { Column { HorizontalDivider(color = Color(0xFFEBEBEB)) Row( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a45e1074..e0fc3a04 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,12 +45,7 @@ compose-stable-marker = "1.0.3" compose-investigator = "1.5.11-0.2.1" landscapist = "2.3.2" balloon = "1.6.4" - -junit = "4.13.2" -androidx-junit = "1.1.5" -espresso-core = "3.5.1" -appcompat = "1.6.1" -material = "1.11.0" +calendar-compose = "2.5.0" [libraries] @@ -104,12 +99,7 @@ accompanist-webview = { group = "com.google.accompanist", name = "accompanist-we compose-stable-marker = { group = "com.github.skydoves", name = "compose-stable-marker", version.ref = "compose-stable-marker" } landscapist-coil = { group = "com.github.skydoves", name = "landscapist-coil", version.ref = "landscapist" } ballon = { group = "com.github.skydoves", name = "balloon", version.ref = "balloon" } - -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } +calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendar-compose"} [plugins]