diff --git a/Prezel/core/designsystem/build.gradle.kts b/Prezel/core/designsystem/build.gradle.kts index 229c0f1d..ab87ab2b 100644 --- a/Prezel/core/designsystem/build.gradle.kts +++ b/Prezel/core/designsystem/build.gradle.kts @@ -10,4 +10,5 @@ android { dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.coil.kt.compose) + implementation(libs.kotlinx.datetime) } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/DatePickerDayCellView.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/DatePickerDayCellView.kt new file mode 100644 index 00000000..3aa2aaf7 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/DatePickerDayCellView.kt @@ -0,0 +1,98 @@ +package com.team.prezel.core.designsystem.component.datepicker + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +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.unit.dp +import com.team.prezel.core.designsystem.preview.ThemePreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.datetime.LocalDate + +@Composable +internal fun RowScope.DayCellView( + uiModel: DayCell?, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .padding(PrezelTheme.spacing.V4) + .clip(CircleShape) + .background( + if (uiModel?.isSelected == true) { + PrezelTheme.colors.interactiveRegular + } else { + Color.Transparent + }, + ).clickable( + indication = ripple(), + interactionSource = null, + enabled = uiModel?.isVisible == true, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + if (uiModel == null) return@Box + if (!uiModel.isVisible) return@Box + + Text( + text = uiModel.dayText, + color = uiModel.dayTextColor(), + style = if (uiModel.isSelected) { + PrezelTheme.typography.body3Bold + } else { + PrezelTheme.typography.body3Medium + }, + ) + } +} + +@ThemePreview +@Composable +private fun DayCellViewPreview() { + PrezelTheme { + Row(modifier = Modifier.width(320.dp)) { + DayCellView( + uiModel = DayCell( + date = LocalDate(2024, 1, 1), + isSelected = false, + isToday = false, + isVisible = true, + ), + onClick = {}, + ) + DayCellView( + uiModel = DayCell( + date = LocalDate(2024, 1, 2), + isSelected = true, + isToday = false, + isVisible = true, + ), + onClick = {}, + ) + DayCellView( + uiModel = DayCell( + date = LocalDate(2024, 1, 3), + isSelected = false, + isToday = true, + isVisible = true, + ), + onClick = {}, + ) + } + } +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/DatePickerStyle.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/DatePickerStyle.kt new file mode 100644 index 00000000..ebffaafc --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/DatePickerStyle.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.designsystem.component.datepicker + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun DayCell.dayTextColor(): Color = + when { + this.isSelected -> PrezelTheme.colors.bgRegular + this.isToday -> PrezelTheme.colors.interactiveRegular + this.isSunday -> PrezelTheme.colors.accentMagentaRegular + else -> PrezelTheme.colors.textMedium + } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/DayCell.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/DayCell.kt new file mode 100644 index 00000000..fce69a12 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/DayCell.kt @@ -0,0 +1,16 @@ +package com.team.prezel.core.designsystem.component.datepicker + +import androidx.compose.runtime.Immutable +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate + +@Immutable +internal data class DayCell( + val date: LocalDate, + val isSelected: Boolean, + val isToday: Boolean, + val isVisible: Boolean, +) { + val dayText: String = date.day.toString() + val isSunday: Boolean = date.dayOfWeek == DayOfWeek.SUNDAY +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/MonthGrid.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/MonthGrid.kt new file mode 100644 index 00000000..0d70efe2 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/MonthGrid.kt @@ -0,0 +1,82 @@ +package com.team.prezel.core.designsystem.component.datepicker + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.team.prezel.core.designsystem.preview.ThemePreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth + +@Composable +internal fun MonthGrid( + yearMonth: YearMonth, + selectedDate: LocalDate?, + today: LocalDate, + onSelect: (LocalDate) -> Unit, +) { + val (cells, lastWeek) = remember(yearMonth) { + val c = buildMonthGrid(yearMonth = yearMonth, firstDayOfWeek = DayOfWeek.SUNDAY) + c to lastWeekIndexToRender(c) + } + + Column(modifier = Modifier.padding(top = PrezelTheme.spacing.V16)) { + for (week in 0..lastWeek) { + WeekRow(cells = cells, week = week, selectedDate = selectedDate, today = today, onSelect = onSelect) + } + } +} + +@Composable +private fun WeekRow( + cells: ImmutableList, + week: Int, + selectedDate: LocalDate?, + today: LocalDate, + onSelect: (LocalDate) -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth()) { + for (day in 0 until 7) { + val date = cells[week * 7 + day] + + if (date == null) { + DayCellView( + uiModel = null, + ) { } + continue + } + + val isPast = date < today + val uiModel = DayCell( + date = date, + isSelected = date == selectedDate, + isToday = date == today, + isVisible = !isPast, + ) + + DayCellView( + uiModel = uiModel, + onClick = { onSelect(date) }, + ) + } + } +} + +@ThemePreview +@Composable +private fun MonthGridPreview() { + PrezelTheme { + MonthGrid( + yearMonth = YearMonth(year = 2026, month = 2), + selectedDate = LocalDate(year = 2026, month = 2, day = 26), + today = LocalDate(year = 2026, month = 2, day = 25), + onSelect = {}, + ) + } +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/MonthGridBuilder.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/MonthGridBuilder.kt new file mode 100644 index 00000000..64e2ac4c --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/MonthGridBuilder.kt @@ -0,0 +1,40 @@ +package com.team.prezel.core.designsystem.component.datepicker + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.onDay + +internal fun buildMonthGrid( + yearMonth: YearMonth, + firstDayOfWeek: DayOfWeek, +): ImmutableList { + val firstOfMonth = yearMonth.onDay(1) + val lastDay = yearMonth.numberOfDays + + val shift = ((firstOfMonth.dayOfWeek.isoDayNumber - firstDayOfWeek.isoDayNumber) + 7) % 7 + val totalCells = 42 + + return (0 until totalCells) + .map { index -> + val dayNumber = index - shift + 1 + if (dayNumber in 1..lastDay) { + yearMonth.onDay(dayNumber) + } else { + null + } + }.toPersistentList() +} + +internal fun lastWeekIndexToRender(cells: List): Int { + // 마지막으로 실제 날짜가 존재하는 셀 인덱스 (0..41) + val last = cells.indexOfLast { it != null } + // month가 비정상일 경우 방어 + if (last < 0) return 0 + + // 주 단위로 올림 → 마지막 날짜가 포함된 주 index (0..5) + return (last / 7).coerceIn(0, 5) +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/MonthSection.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/MonthSection.kt new file mode 100644 index 00000000..12a57bbd --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/MonthSection.kt @@ -0,0 +1,56 @@ +package com.team.prezel.core.designsystem.component.datepicker + +import androidx.compose.foundation.layout.Column +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.res.stringResource +import com.team.prezel.core.designsystem.R +import com.team.prezel.core.designsystem.preview.ThemePreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth +import kotlinx.datetime.number + +@Composable +internal fun MonthSection( + yearMonth: YearMonth, + selectedDate: LocalDate?, + today: LocalDate, + onSelect: (LocalDate) -> Unit, +) { + Column( + modifier = Modifier.padding(PrezelTheme.spacing.V20), + ) { + Text( + text = stringResource( + id = R.string.core_designsystem_date_picker_month_title, + yearMonth.year, + yearMonth.month.number, + ), + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.body3Medium, + ) + + MonthGrid( + yearMonth = yearMonth, + selectedDate = selectedDate, + today = today, + onSelect = onSelect, + ) + } +} + +@ThemePreview +@Composable +private fun MonthSectionPreview() { + PrezelTheme { + MonthSection( + yearMonth = YearMonth(year = 2026, month = 2), + selectedDate = LocalDate(year = 2026, month = 2, day = 26), + today = LocalDate(year = 2026, month = 2, day = 25), + onSelect = {}, + ) + } +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt new file mode 100644 index 00000000..51730dce --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt @@ -0,0 +1,171 @@ +package com.team.prezel.core.designsystem.component.datepicker + +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.team.prezel.core.designsystem.R +import com.team.prezel.core.designsystem.component.PrezelDividerType +import com.team.prezel.core.designsystem.component.PrezelHorizontalDivider +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.component.button.ButtonAreaButtonSpec +import com.team.prezel.core.designsystem.component.button.PrezelButtonArea +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.plus +import kotlinx.datetime.todayIn +import kotlin.time.Clock + +@Composable +fun PrezelDatePicker( + title: String, + selectedDate: LocalDate?, + onSelect: (LocalDate) -> Unit, + onClose: () -> Unit, + onConfirm: (LocalDate) -> Unit, + modifier: Modifier = Modifier, + today: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()), +) { + val initialMonth = remember(today) { YearMonth(today.year, today.month) } + val months = remember(initialMonth) { + List(12) { offset -> + initialMonth.plus(value = offset, unit = DateTimeUnit.MONTH) + } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + DatePickerHeader(title = title, onClose = onClose) + + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = PrezelTheme.spacing.V16), + overscrollEffect = null, + ) { + items(items = months, key = { it.toString() }) { month -> + MonthSection( + yearMonth = month, + selectedDate = selectedDate, + today = today, + onSelect = onSelect, + ) + } + } + + DatePickerFooter(selectedDate = selectedDate, onConfirm = onConfirm) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerHeader( + title: String, + onClose: () -> Unit, +) { + Column { + PrezelTopAppBar( + title = { Text(text = title) }, + trailingIcons = { + IconButton(onClick = onClose) { + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = stringResource(R.string.core_designsystem_close_date_picker_desc), + ) + } + }, + ) + WeekdayRow() + PrezelHorizontalDivider(type = PrezelDividerType.THICK) + } +} + +@Composable +private fun DatePickerFooter( + selectedDate: LocalDate?, + onConfirm: (LocalDate) -> Unit, +) { + PrezelButtonArea( + mainButton = ButtonAreaButtonSpec( + label = stringResource(R.string.core_designsystem_date_picker_confirm_btn), + enabled = selectedDate != null, + onClick = { selectedDate?.let(onConfirm) }, + ), + subButton = null, + isVertical = false, + showBackground = true, + ) +} + +@Composable +private fun WeekdayRow() { + val labels = stringArrayResource(R.array.core_designsystem_weekday_labels).toList() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = PrezelTheme.spacing.V20, + vertical = PrezelTheme.spacing.V12, + ), + ) { + labels.forEach { text -> + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = PrezelTheme.colors.textMedium, + style = PrezelTheme.typography.body3Medium, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PrezelDatePickerPreview() { + PrezelTheme { + var selected by remember { + mutableStateOf(LocalDate(year = 2026, month = 2, day = 26)) + } + + PrezelDatePicker( + title = "발표 날짜", + today = LocalDate(year = 2026, month = 2, day = 23), + selectedDate = selected, + onSelect = { selected = it }, + onClose = {}, + onConfirm = {}, + ) + } +} diff --git a/Prezel/core/designsystem/src/main/res/values/strings.xml b/Prezel/core/designsystem/src/main/res/values/strings.xml index 519ffa15..65940253 100644 --- a/Prezel/core/designsystem/src/main/res/values/strings.xml +++ b/Prezel/core/designsystem/src/main/res/values/strings.xml @@ -1,5 +1,18 @@ 플로팅 버튼 닫기 + 날짜 선택 닫기 체크박스 + 선택하기 + %1$d년 %2$d월 + + + + + + + + + + diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 0c8088a5..a51a5f81 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -1,8 +1,6 @@ [versions] agp = "8.13.2" kotlin = "2.3.0" -kotlinMetadataJvm = "2.3.0" -kotlinxCoroutines = "1.10.2" coil = "2.7.0" coreKtx = "1.17.0" junit = "4.13.2" @@ -16,13 +14,16 @@ detekt = "1.23.8" ksp = "2.3.4" hilt = "2.58" desugarJdk = "2.1.5" -kotlintxCollectionsImmutable = "0.4.0" ktor = "3.3.3" ktorfit = "2.7.2" -kotlinx-serialization = "1.9.0" timber = "5.0.1" navigation3 = "1.0.0" +kotlinxDatetime = "0.7.1" +kotlinxCoroutines = "1.10.2" +kotlinxCollectionsImmutable = "0.4.0" +kotlinxSerialization = "1.9.0" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -45,12 +46,9 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } -kotlin-metadata-jvm = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlinMetadataJvm" } +kotlin-metadata-jvm = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlin" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } -kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlintxCollectionsImmutable" } -kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } -kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } -kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } + ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } @@ -58,10 +56,16 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktorfit-lib = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" } ktorfit-ksp = { module = "de.jensklingenberg.ktorfit:ktorfit-ksp", version.ref = "ktorfit" } -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + # Dependencies of the included build-logic android-gradleApiPlugin = { group = "com.android.tools.build", name = "gradle-api", version.ref = "agp" } compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }