diff --git a/.github/workflows/integration-build-test.yml b/.github/workflows/integration-build-test.yml index 4e92381..5c8855c 100644 --- a/.github/workflows/integration-build-test.yml +++ b/.github/workflows/integration-build-test.yml @@ -12,7 +12,8 @@ permissions: contents: read env: - MODULE: ":datetimepicker" + LIBRARY_MODULE: ":datetimepicker" + SAMPLE_MODULE: ":sample" jobs: linux-builds: @@ -26,14 +27,15 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: gradle/wrapper-validation-action@v2 + - uses: gradle/actions/wrapper-validation@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - cache: 'gradle' + + - uses: gradle/actions/setup-gradle@v4 # Android Set Up - name: Set up Android SDK @@ -44,39 +46,42 @@ jobs: if: matrix.target == 'android' run: | sdkmanager "platform-tools" \ - "platforms;android-34" \ - "build-tools;34.0.0" || true - yes | sdkmanager --licenses || true + "platforms;android-36" \ + "build-tools;36.0.0" + yes | sdkmanager --licenses # Android Build/Unit Test - name: Gradle Build • Android if: matrix.target == 'android' - uses: gradle/gradle-build-action@v3 - with: - arguments: | - ${{ env.MODULE }}:clean - ${{ env.MODULE }}:assembleRelease - ${{ env.MODULE }}:testReleaseUnitTest + run: | + ./gradlew \ + ${{ env.LIBRARY_MODULE }}:clean \ + ${{ env.LIBRARY_MODULE }}:assembleRelease \ + ${{ env.LIBRARY_MODULE }}:testReleaseUnitTest \ + ${{ env.SAMPLE_MODULE }}:assembleDebug \ + --no-daemon # Web(WASM) Build/Test - name: Gradle Build • Web (WASM) if: matrix.target == 'wasm' - uses: gradle/gradle-build-action@v3 - with: - arguments: | - ${{ env.MODULE }}:clean - ${{ env.MODULE }}:wasmJsBrowserDistribution - ${{ env.MODULE }}:wasmJsTest + run: | + ./gradlew \ + ${{ env.LIBRARY_MODULE }}:clean \ + ${{ env.LIBRARY_MODULE }}:wasmJsBrowserDistribution \ + ${{ env.LIBRARY_MODULE }}:wasmJsTest \ + ${{ env.SAMPLE_MODULE }}:wasmJsBrowserDistribution \ + --no-daemon # Desktop(JVM) Build/Test - name: Gradle Build • Desktop (JVM) if: matrix.target == 'desktop' - uses: gradle/gradle-build-action@v3 - with: - arguments: | - ${{ env.MODULE }}:clean - ${{ env.MODULE }}:desktopJar - ${{ env.MODULE }}:desktopTest + run: | + ./gradlew \ + ${{ env.LIBRARY_MODULE }}:clean \ + ${{ env.LIBRARY_MODULE }}:desktopJar \ + ${{ env.LIBRARY_MODULE }}:desktopTest \ + ${{ env.SAMPLE_MODULE }}:compileKotlinDesktop \ + --no-daemon ios-build-and-test: name: macOS • iOS Simulator Test @@ -85,14 +90,15 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: gradle/wrapper-validation-action@v2 + - uses: gradle/actions/wrapper-validation@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - cache: 'gradle' + + - uses: gradle/actions/setup-gradle@v4 - name: Boot iOS Simulator (iPhone 15) run: | @@ -104,8 +110,9 @@ jobs: # iOS Simulator Unit Test - name: Gradle Build & Test • iOS - uses: gradle/gradle-build-action@v3 - with: - arguments: | - ${{ env.MODULE }}:clean - ${{ env.MODULE }}:iosSimulatorArm64Test + run: | + ./gradlew \ + ${{ env.LIBRARY_MODULE }}:clean \ + ${{ env.LIBRARY_MODULE }}:iosSimulatorArm64Test \ + ${{ env.SAMPLE_MODULE }}:compileKotlinIosSimulatorArm64 \ + --no-daemon diff --git a/README.md b/README.md index 3db8d59..2518536 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,12 @@ It provides consistent UI components across Android, iOS, Desktop (JVM), and Web * **Multiplatform Support**: seamless integration for Android, iOS, Desktop (JVM), and Web (Wasm). * **TimePicker**: Supports both 12-hour (AM/PM) and 24-hour formats. +* **DatePicker**: A complete date picker for selecting year, month, and day with automatic day + validation. * **YearMonthPicker**: A dedicated component for selecting years and months. * **Customizable**: Extensible API allowing custom content rendering, styling, and configuration. -* **State Management**: simplified state handling with `rememberTimePickerState` and `rememberYearMonthPickerState`. +* **State Management**: simplified state handling with `rememberTimePickerState`, + `rememberDatePickerState`, and `rememberYearMonthPickerState`. * **Accessibility**: Built with accessibility in mind, supporting screen readers and navigation. ## Installation @@ -22,7 +25,7 @@ Add the dependency to your version catalog or build file. ```toml [versions] -composeDateTimePicker = "0.4.0" +composeDateTimePicker = "0.5.0" [libraries] compose-date-time-picker = { module = "io.github.kez-lab:compose-date-time-picker", version.ref = "composeDateTimePicker" } @@ -32,7 +35,7 @@ compose-date-time-picker = { module = "io.github.kez-lab:compose-date-time-picke ```kotlin dependencies { - implementation("io.github.kez-lab:compose-date-time-picker:0.4.0") + implementation("io.github.kez-lab:compose-date-time-picker:0.5.0") } ``` @@ -55,8 +58,8 @@ import com.kez.picker.util.currentMinute @Composable fun TimePicker24hExample() { val state = rememberTimePickerState( - initialHour = currentHour, - initialMinute = currentMinute, + initialHour = currentHour(), + initialMinute = currentMinute(), timeFormat = TimeFormat.HOUR_24 ) @@ -73,7 +76,6 @@ import androidx.compose.runtime.Composable import com.kez.picker.time.TimePicker import com.kez.picker.rememberTimePickerState import com.kez.picker.util.TimeFormat -import com.kez.picker.util.TimePeriod import com.kez.picker.util.currentHour import com.kez.picker.util.currentMinute @@ -81,8 +83,8 @@ import com.kez.picker.util.currentMinute fun TimePicker12hExample() { // Handling of 12-hour format conversion is now done internally by the state val state = rememberTimePickerState( - initialHour = currentHour, - initialMinute = currentMinute, + initialHour = currentHour(), + initialMinute = currentMinute(), timeFormat = TimeFormat.HOUR_12 ) @@ -92,6 +94,35 @@ fun TimePicker12hExample() { } ``` +### DatePicker + +Use `DatePicker` for selecting a complete date (year, month, and day). The component automatically +adjusts the day when the selected month changes (e.g., Feb 30 → Feb 28). + +```kotlin +import androidx.compose.runtime.Composable +import com.kez.picker.date.DatePicker +import com.kez.picker.date.rememberDatePickerState +import com.kez.picker.util.currentDate +import com.kez.picker.util.currentMonth + +@Composable +fun DatePickerExample() { + val state = rememberDatePickerState( + initialYear = currentDate().year, + initialMonth = currentMonth(), + initialDay = currentDate().day + ) + + DatePicker( + state = state + ) + + // Access selected values + // state.selectedYear, state.selectedMonth, state.selectedDay +} +``` + ### YearMonthPicker Use `YearMonthPicker` for selecting a specific month in a year. @@ -101,12 +132,13 @@ import androidx.compose.runtime.Composable import com.kez.picker.date.YearMonthPicker import com.kez.picker.rememberYearMonthPickerState import com.kez.picker.util.currentDate +import com.kez.picker.util.currentMonth @Composable fun YearMonthPickerExample() { val state = rememberYearMonthPickerState( - initialYear = currentDate.year, - initialMonth = currentDate.monthNumber + initialYear = currentDate().year, + initialMonth = currentMonth() ) YearMonthPicker( @@ -124,7 +156,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import com.kez.picker.time.TimePicker import com.kez.picker.rememberTimePickerState -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -132,7 +163,6 @@ fun BottomSheetPickerExample() { var showBottomSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState() val state = rememberTimePickerState() - val scope = rememberCoroutineScope() Button(onClick = { showBottomSheet = true }) { Text("Select Time") @@ -157,21 +187,44 @@ fun BottomSheetPickerExample() { | Parameter | Description | Default | | :--- | :--- | :--- | | `state` | The state object to control the picker. | `rememberTimePickerState()` | -| `startTime` | The initial time to set the picker to. | `currentDateTime` | +| `startTime` | Legacy initial time parameter. Prefer setting initial values in `rememberTimePickerState`. | `currentDateTime()` | +| `minuteItems` | Minute values available for selection. | `0..59` | +| `hourItems` | Hour values available for selection. | `0..23` or `1..12` | | `visibleItemsCount` | Number of items visible in the list. | `3` | -| `textStyle` | Style for unselected items. | `16.sp` | -| `selectedTextStyle` | Style for selected item. | `22.sp` | -| `dividerColor` | Color of the selection dividers. | `LocalContentColor.current` | +| `colors` | Colors for text, selected text, dividers, and selected item background. | `PickerDefaults.colors()` | +| `textStyles` | Text styles for selected and unselected items. | `PickerDefaults.textStyles()` | +| `isDividerVisible` | Whether selection dividers are visible. | `true` | + +### DatePicker + +| Parameter | Description | Default | +|:--------------------|:----------------------------------------|:----------------------------| +| `state` | The state object to control the picker. | `rememberDatePickerState()` | +| `startLocalDate` | Legacy initial date parameter. Prefer setting initial values in `rememberDatePickerState`. | `currentDate()` | +| `yearItems` | List of years available for selection. | `1000..9999` | +| `monthItems` | List of months available for selection. | `1..12` | +| `visibleItemsCount` | Number of items visible in the list. | `3` | +| `colors` | Colors for text, selected text, dividers, and selected item background. | `PickerDefaults.colors()` | +| `textStyles` | Text styles for selected and unselected items. | `PickerDefaults.textStyles()` | + +**DatePickerState Properties:** + +- `selectedYear`: The currently selected year. +- `selectedMonth`: The currently selected month (1-12). +- `selectedDay`: The currently selected day (1-31, auto-adjusted based on month). +- `maxDay`: The maximum valid day for the selected year and month. ### YearMonthPicker | Parameter | Description | Default | | :--- | :--- | :--- | | `state` | The state object to control the picker. | `rememberYearMonthPickerState()` | -| `startLocalDate` | The initial date to set the picker to. | `currentDate` | -| `yearItems` | List of years available for selection. | `1900..2100` | +| `startLocalDate` | Legacy initial date parameter. Prefer setting initial values in `rememberYearMonthPickerState`. | `currentDate()` | +| `yearItems` | List of years available for selection. | `1000..9999` | | `monthItems` | List of months available for selection. | `1..12` | | `visibleItemsCount` | Number of items visible in the list. | `3` | +| `colors` | Colors for text, selected text, dividers, and selected item background. | `PickerDefaults.colors()` | +| `textStyles` | Text styles for selected and unselected items. | `PickerDefaults.textStyles()` | ## License diff --git a/README_KO.md b/README_KO.md index 3b13865..9dc05ea 100644 --- a/README_KO.md +++ b/README_KO.md @@ -9,9 +9,10 @@ Android, iOS, Desktop (JVM), Web (Wasm) 등 다양한 플랫폼에서 일관된 * **멀티플랫폼 지원**: Android, iOS, Desktop (JVM), Web (Wasm) 환경을 지원하며 원활한 통합이 가능합니다. * **TimePicker**: 12시간(오전/오후) 및 24시간 형식을 모두 지원합니다. +* **DatePicker**: 연도, 월, 일을 함께 선택하고 월/윤년에 맞춰 일을 자동 보정합니다. * **YearMonthPicker**: 년도와 월을 선택할 수 있는 전용 컴포넌트를 제공합니다. * **커스터마이징**: 커스텀 아이템 렌더링, 스타일링, 구성 변경이 가능한 유연한 API를 제공합니다. -* **상태 관리**: `rememberTimePickerState` 및 `rememberYearMonthPickerState`를 통해 간편하게 상태를 관리할 수 있습니다. +* **상태 관리**: `rememberTimePickerState`, `rememberDatePickerState`, `rememberYearMonthPickerState`를 통해 간편하게 상태를 관리할 수 있습니다. * **접근성**: 스크린 리더 및 내비게이션 지원 등 접근성을 고려하여 설계되었습니다. ## 설치 방법 @@ -22,7 +23,7 @@ Android, iOS, Desktop (JVM), Web (Wasm) 등 다양한 플랫폼에서 일관된 ```toml [versions] -composeDateTimePicker = "0.4.0" +composeDateTimePicker = "0.5.0" [libraries] compose-date-time-picker = { module = "io.github.kez-lab:compose-date-time-picker", version.ref = "composeDateTimePicker" } @@ -32,7 +33,7 @@ compose-date-time-picker = { module = "io.github.kez-lab:compose-date-time-picke ```kotlin dependencies { - implementation("io.github.kez-lab:compose-date-time-picker:0.4.0") + implementation("io.github.kez-lab:compose-date-time-picker:0.5.0") } ``` @@ -55,8 +56,8 @@ import com.kez.picker.util.currentMinute @Composable fun TimePicker24hExample() { val state = rememberTimePickerState( - initialHour = currentHour, - initialMinute = currentMinute, + initialHour = currentHour(), + initialMinute = currentMinute(), timeFormat = TimeFormat.HOUR_24 ) @@ -73,7 +74,6 @@ import androidx.compose.runtime.Composable import com.kez.picker.time.TimePicker import com.kez.picker.rememberTimePickerState import com.kez.picker.util.TimeFormat -import com.kez.picker.util.TimePeriod import com.kez.picker.util.currentHour import com.kez.picker.util.currentMinute @@ -81,8 +81,8 @@ import com.kez.picker.util.currentMinute fun TimePicker12hExample() { // 12시간 형식 변환은 이제 state 내부에서 처리됩니다. val state = rememberTimePickerState( - initialHour = currentHour, - initialMinute = currentMinute, + initialHour = currentHour(), + initialMinute = currentMinute(), timeFormat = TimeFormat.HOUR_12 ) @@ -92,6 +92,31 @@ fun TimePicker12hExample() { } ``` +### DatePicker + +연도, 월, 일을 함께 선택할 때 `DatePicker`를 사용합니다. 선택된 월에 유효하지 않은 일이 있으면 자동으로 보정됩니다. + +```kotlin +import androidx.compose.runtime.Composable +import com.kez.picker.date.DatePicker +import com.kez.picker.date.rememberDatePickerState +import com.kez.picker.util.currentDate +import com.kez.picker.util.currentMonth + +@Composable +fun DatePickerExample() { + val state = rememberDatePickerState( + initialYear = currentDate().year, + initialMonth = currentMonth(), + initialDay = currentDate().day + ) + + DatePicker(state = state) + + // state.selectedYear, state.selectedMonth, state.selectedDay +} +``` + ### YearMonthPicker 특정 연도와 월을 선택할 때 `YearMonthPicker`를 사용합니다. @@ -101,12 +126,13 @@ import androidx.compose.runtime.Composable import com.kez.picker.date.YearMonthPicker import com.kez.picker.rememberYearMonthPickerState import com.kez.picker.util.currentDate +import com.kez.picker.util.currentMonth @Composable fun YearMonthPickerExample() { val state = rememberYearMonthPickerState( - initialYear = currentDate.year, - initialMonth = currentDate.monthNumber + initialYear = currentDate().year, + initialMonth = currentMonth() ) YearMonthPicker( @@ -124,7 +150,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import com.kez.picker.time.TimePicker import com.kez.picker.rememberTimePickerState -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -132,7 +157,6 @@ fun BottomSheetPickerExample() { var showBottomSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState() val state = rememberTimePickerState() - val scope = rememberCoroutineScope() Button(onClick = { showBottomSheet = true }) { Text("시간 선택") @@ -157,21 +181,44 @@ fun BottomSheetPickerExample() { | 파라미터 | 설명 | 기본값 | | :--- | :--- | :--- | | `state` | Picker를 제어하기 위한 상태 객체입니다. | `rememberTimePickerState()` | -| `startTime` | Picker에 설정될 초기 시간입니다. | `currentDateTime` | +| `startTime` | 레거시 초기 시간 파라미터입니다. 초기값은 `rememberTimePickerState`에서 설정하는 방식을 권장합니다. | `currentDateTime()` | +| `minuteItems` | 선택 가능한 분 목록입니다. | `0..59` | +| `hourItems` | 선택 가능한 시간 목록입니다. | `0..23` 또는 `1..12` | +| `visibleItemsCount` | 리스트에 표시될 아이템의 개수입니다. | `3` | +| `colors` | 텍스트, 선택 텍스트, 구분선, 선택 영역 배경 색상입니다. | `PickerDefaults.colors()` | +| `textStyles` | 선택/비선택 아이템의 텍스트 스타일입니다. | `PickerDefaults.textStyles()` | +| `isDividerVisible` | 선택 영역 구분선 표시 여부입니다. | `true` | + +### DatePicker + +| 파라미터 | 설명 | 기본값 | +| :--- | :--- | :--- | +| `state` | Picker를 제어하기 위한 상태 객체입니다. | `rememberDatePickerState()` | +| `startLocalDate` | 레거시 초기 날짜 파라미터입니다. 초기값은 `rememberDatePickerState`에서 설정하는 방식을 권장합니다. | `currentDate()` | +| `yearItems` | 선택 가능한 연도 목록입니다. | `1000..9999` | +| `monthItems` | 선택 가능한 월 목록입니다. | `1..12` | | `visibleItemsCount` | 리스트에 표시될 아이템의 개수입니다. | `3` | -| `textStyle` | 선택되지 않은 아이템의 텍스트 스타일입니다. | `16.sp` | -| `selectedTextStyle` | 선택된 아이템의 텍스트 스타일입니다. | `22.sp` | -| `dividerColor` | 구분선의 색상입니다. | `LocalContentColor.current` | +| `colors` | 텍스트, 선택 텍스트, 구분선, 선택 영역 배경 색상입니다. | `PickerDefaults.colors()` | +| `textStyles` | 선택/비선택 아이템의 텍스트 스타일입니다. | `PickerDefaults.textStyles()` | + +**DatePickerState 속성:** + +- `selectedYear`: 현재 선택된 연도입니다. +- `selectedMonth`: 현재 선택된 월입니다. (1-12) +- `selectedDay`: 현재 선택된 일입니다. 선택된 월에 맞게 자동 보정됩니다. +- `maxDay`: 현재 선택된 연도/월에서 선택 가능한 최대 일입니다. ### YearMonthPicker | 파라미터 | 설명 | 기본값 | | :--- | :--- | :--- | | `state` | Picker를 제어하기 위한 상태 객체입니다. | `rememberYearMonthPickerState()` | -| `startLocalDate` | Picker에 설정될 초기 날짜입니다. | `currentDate` | -| `yearItems` | 선택 가능한 연도 목록입니다. | `1900..2100` | +| `startLocalDate` | 레거시 초기 날짜 파라미터입니다. 초기값은 `rememberYearMonthPickerState`에서 설정하는 방식을 권장합니다. | `currentDate()` | +| `yearItems` | 선택 가능한 연도 목록입니다. | `1000..9999` | | `monthItems` | 선택 가능한 월 목록입니다. | `1..12` | | `visibleItemsCount` | 리스트에 표시될 아이템의 개수입니다. | `3` | +| `colors` | 텍스트, 선택 텍스트, 구분선, 선택 영역 배경 색상입니다. | `PickerDefaults.colors()` | +| `textStyles` | 선택/비선택 아이템의 텍스트 스타일입니다. | `PickerDefaults.textStyles()` | ## 라이선스 diff --git a/datetimepicker/build.gradle.kts b/datetimepicker/build.gradle.kts index ef88ebe..403e994 100644 --- a/datetimepicker/build.gradle.kts +++ b/datetimepicker/build.gradle.kts @@ -34,14 +34,14 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(libs.compose.runtime) - implementation(libs.compose.foundation) + api(libs.compose.runtime) + api(libs.compose.foundation) + api(libs.compose.ui) + api(libs.kotlinx.datetime) + implementation(libs.compose.material3) - implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.components.resources) - - implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines.core) } diff --git a/datetimepicker/src/commonMain/kotlin/com/kez/picker/Picker.kt b/datetimepicker/src/commonMain/kotlin/com/kez/picker/Picker.kt index f751435..c996d59 100644 --- a/datetimepicker/src/commonMain/kotlin/com/kez/picker/Picker.kt +++ b/datetimepicker/src/commonMain/kotlin/com/kez/picker/Picker.kt @@ -12,10 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -27,21 +24,30 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.semantics.Role import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.semantics.CollectionInfo +import androidx.compose.ui.semantics.CollectionItemInfo +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.collectionInfo +import androidx.compose.ui.semantics.collectionItemInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import kotlinx.coroutines.flow.distinctUntilChanged @@ -49,27 +55,35 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlin.math.abs +/** + * Multiplier for infinite scroll list size. + * Uses a reasonable multiplier instead of Int.MAX_VALUE to prevent memory issues + * while still providing a virtually infinite scrolling experience. + */ +private const val INFINITE_SCROLL_MULTIPLIER = 1000 + /** * A generic picker component that displays a list of items and allows the user to select one. + * Follows Material3 component design patterns. * * @param items The list of items to display. - * @param modifier The modifier to be applied to the picker. * @param state The state of the picker. + * @param modifier The modifier to be applied to the picker. * @param startIndex The initial index to display. - * @param visibleItemsCount The number of items visible at once. - * @param textStyle The style of the text for unselected items. - * @param selectedTextStyle The style of the text for the selected item. - * @param dividerColor The color of the dividers. - * @param selectedItemBackgroundColor The background color of the selected item area. + * @param visibleItemsCount The number of items visible at once. Must be a positive odd number. + * @param colors The colors used by the picker. See [PickerDefaults.colors]. + * @param textStyles The text styles used by the picker. See [PickerDefaults.textStyles]. * @param selectedItemBackgroundShape The shape of the background of the selected item area. * @param itemPadding The padding around each item. * @param fadingEdgeGradient The gradient to use for fading edges. * @param horizontalAlignment The horizontal alignment of items. - * @param itemTextAlignment The vertical alignment of the text within items. + * @param verticalAlignment The vertical alignment of the text within items. * @param dividerThickness The thickness of the dividers. * @param dividerShape The shape of the dividers. * @param isDividerVisible Whether the divider should be visible. * @param isInfinity Whether the picker should loop infinitely. + * @param pickerLabel Accessibility label for the picker (e.g., "Hour", "Minute", "Year"). + * @param content Optional custom content composable for rendering each item. */ @Composable fun Picker( @@ -77,62 +91,87 @@ fun Picker( state: PickerState, modifier: Modifier = Modifier, startIndex: Int = 0, - visibleItemsCount: Int = 3, - textStyle: TextStyle = LocalTextStyle.current, - selectedTextStyle: TextStyle = LocalTextStyle.current, - dividerColor: Color = LocalContentColor.current, - selectedItemBackgroundColor: Color = Color.Transparent, - selectedItemBackgroundShape: Shape = RoundedCornerShape(12.dp), - itemPadding: PaddingValues = PaddingValues(8.dp), - fadingEdgeGradient: Brush = Brush.verticalGradient( - 0f to Color.Transparent, - 0.5f to Color.Black, - 1f to Color.Transparent - ), + visibleItemsCount: Int = PickerDefaults.VisibleItemsCount, + colors: PickerColors = PickerDefaults.colors(), + textStyles: PickerTextStyles = PickerDefaults.textStyles(), + selectedItemBackgroundShape: Shape = PickerDefaults.SelectedItemBackgroundShape, + itemPadding: PaddingValues = PickerDefaults.ItemPadding, + fadingEdgeGradient: Brush = PickerDefaults.fadingEdgeGradient(), horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, - itemTextAlignment: Alignment.Vertical = Alignment.CenterVertically, - dividerThickness: Dp = 1.dp, - dividerShape: Shape = RoundedCornerShape(10.dp), + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + dividerThickness: Dp = PickerDefaults.DividerThickness, + dividerShape: Shape = PickerDefaults.DividerShape, isDividerVisible: Boolean = true, isInfinity: Boolean = true, + pickerLabel: String? = null, content: @Composable ((T) -> Unit)? = null ) { + require(items.isNotEmpty()) { "Items list must not be empty" } + require(visibleItemsCount > 0 && visibleItemsCount % 2 == 1) { + "visibleItemsCount must be a positive odd number, but was $visibleItemsCount" + } + require(startIndex >= 0 && startIndex < items.size) { + "startIndex must be in range [0, ${items.size}), but was $startIndex" + } + val density = LocalDensity.current val visibleItemsMiddle = remember { visibleItemsCount / 2 } val scope = rememberCoroutineScope() val adjustedItems = if (!isInfinity) { - listOf(null) + items + listOf(null) + List(visibleItemsMiddle) { null } + items + List(visibleItemsMiddle) { null } } else { items } val listScrollCount = if (isInfinity) { - Int.MAX_VALUE + adjustedItems.size * INFINITE_SCROLL_MULTIPLIER } else { adjustedItems.size } - val listScrollMiddle = remember { listScrollCount / 2 } - val listStartIndex = remember { - if (isInfinity) { - listScrollMiddle - listScrollMiddle % adjustedItems.size - visibleItemsMiddle + startIndex + val listScrollMiddle = remember(listScrollCount) { listScrollCount / 2 } + val listStartIndex = + remember(listScrollCount, adjustedItems.size, visibleItemsMiddle, startIndex) { + if (isInfinity) { + listScrollMiddle - listScrollMiddle % adjustedItems.size - visibleItemsMiddle + startIndex + } else { + startIndex + } + } + + fun getItem(index: Int): T? { + if (adjustedItems.isEmpty()) return null + return if (isInfinity) { + val safeIndex = index.mod(adjustedItems.size) + adjustedItems.getOrNull(safeIndex) } else { - startIndex + 1 + adjustedItems.getOrNull(index) } } - fun getItem(index: Int) = adjustedItems[index % adjustedItems.size] - val listState = rememberLazyListState(initialFirstVisibleItemIndex = listStartIndex) val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) + val textStyle = textStyles.textStyle + val selectedTextStyle = textStyles.selectedTextStyle + + val selectedLineHeight = selectedTextStyle.lineHeight.takeIf { it.isSpecified } ?: 0.sp + val unselectedLineHeight = textStyle.lineHeight.takeIf { it.isSpecified } ?: 0.sp + val largestLineHeight = + if (selectedLineHeight > unselectedLineHeight) selectedLineHeight else unselectedLineHeight + + val selectedFontSize = selectedTextStyle.fontSize.takeIf { it.isSpecified } ?: 0.sp + val unselectedFontSize = textStyle.fontSize.takeIf { it.isSpecified } ?: 0.sp + val largestFontSize = + if (selectedFontSize > unselectedFontSize) selectedFontSize else unselectedFontSize + + val textHeightSp = if (largestLineHeight > 0.sp) largestLineHeight else largestFontSize + val itemHeight = with(density) { - selectedTextStyle.fontSize - .toPx() - .toDp() - .plus(itemPadding.calculateTopPadding()) - .plus(itemPadding.calculateBottomPadding()) + textHeightSp.toDp() + + itemPadding.calculateTopPadding() + + itemPadding.calculateBottomPadding() } LaunchedEffect(listState) { @@ -142,12 +181,22 @@ fun Picker( .collect { item -> state.selectedItem = item } } - Box(modifier = modifier) { + Box( + modifier = modifier.semantics { + // Provide picker-level accessibility information + pickerLabel?.let { label -> + contentDescription = "$label picker, currently selected: ${state.selectedItem}" + stateDescription = "Selected: ${state.selectedItem}" + liveRegion = LiveRegionMode.Polite + } + collectionInfo = CollectionInfo(rowCount = items.size, columnCount = 1) + } + ) { Box( modifier = Modifier .align(Alignment.Center) .background( - color = selectedItemBackgroundColor, + color = colors.selectedItemBackgroundColor, shape = selectedItemBackgroundShape ) .fillMaxWidth() @@ -155,30 +204,33 @@ fun Picker( ) { if (isDividerVisible) { HorizontalDivider( - color = dividerColor, + color = colors.dividerColor, thickness = dividerThickness, modifier = Modifier .fillMaxWidth() .background( - color = dividerColor, + color = colors.dividerColor, shape = dividerShape ) .align(Alignment.TopCenter) ) HorizontalDivider( - color = dividerColor, + color = colors.dividerColor, thickness = dividerThickness, modifier = Modifier .fillMaxWidth() .background( - color = dividerColor, + color = colors.dividerColor, shape = dividerShape ) .align(Alignment.BottomCenter) ) } } + + val sharedInteractionSource = remember { MutableInteractionSource() } + LazyColumn( state = listState, flingBehavior = flingBehavior, @@ -207,18 +259,44 @@ fun Picker( } val item = getItem(index) - + val isSelected = item == state.selectedItem + val itemDescription = item?.toString() ?: "" + val itemIndex = if (isInfinity) { + index % items.size + } else { + (index - visibleItemsMiddle).coerceAtLeast(0) + } + Box( modifier = Modifier .height(itemHeight) .fillMaxWidth() + .semantics { + if (item != null) { + role = Role.Button + // Enhanced content description with picker context + contentDescription = if (pickerLabel != null) { + "$pickerLabel: $itemDescription${if (isSelected) ", selected" else ""}" + } else { + "$itemDescription${if (isSelected) ", selected" else ""}" + } + selected = isSelected + collectionItemInfo = CollectionItemInfo( + rowIndex = itemIndex, + rowSpan = 1, + columnIndex = 0, + columnSpan = 1 + ) + } + } .clickable( enabled = item != null, role = Role.Button, indication = null, - interactionSource = remember { MutableInteractionSource() }, + interactionSource = sharedInteractionSource, onClick = { - val currentCenterIndex = listState.firstVisibleItemIndex + visibleItemsMiddle + val currentCenterIndex = + listState.firstVisibleItemIndex + visibleItemsMiddle if (index != currentCenterIndex) { scope.launch { val targetIndex = (index - visibleItemsMiddle) @@ -236,19 +314,19 @@ fun Picker( content(item) } else { Text( - text = item.toString(), + text = itemDescription, maxLines = 1, overflow = TextOverflow.Ellipsis, style = textStyle.copy( fontSize = lerp( - selectedTextStyle.fontSize, - textStyle.fontSize, + start = selectedTextStyle.fontSize, + stop = textStyle.fontSize, fraction ), color = lerp( - selectedTextStyle.color, - textStyle.color, - fraction + start = colors.selectedTextColor, + stop = colors.textColor, + fraction = fraction ), ), textAlign = TextAlign.Center @@ -260,6 +338,7 @@ fun Picker( } } } + /** * Apply a fading edge effect to a modifier. * @@ -279,9 +358,7 @@ fun PickerPreview() { val state = rememberPickerState("Item 1") Picker( items = listOf("1", "2", "3"), - state = state, - textStyle = TextStyle(fontSize = 16.sp), - selectedTextStyle = TextStyle(fontSize = 24.sp) + state = state ) } @@ -291,9 +368,7 @@ fun PickerLongTextPreview() { val state = rememberPickerState("Long Item 1") Picker( items = listOf("Long Item 1", "Long Item 2", "Long Item 3"), - state = state, - textStyle = TextStyle(fontSize = 16.sp), - selectedTextStyle = TextStyle(fontSize = 24.sp) + state = state ) } @@ -305,9 +380,7 @@ fun PickerManyItemsPreview() { Picker( items = items, state = state, - startIndex = 49, - textStyle = TextStyle(fontSize = 16.sp), - selectedTextStyle = TextStyle(fontSize = 24.sp) + startIndex = 49 ) } @@ -318,9 +391,7 @@ fun PickerNoDividerPreview() { Picker( items = listOf("Item 1", "Item 2", "Item 3"), state = state, - isDividerVisible = false, - textStyle = TextStyle(fontSize = 16.sp), - selectedTextStyle = TextStyle(fontSize = 24.sp) + isDividerVisible = false ) } @@ -331,9 +402,11 @@ fun PickerCustomColorsPreview() { Picker( items = listOf("Red", "Green", "Blue"), state = state, - textStyle = TextStyle(fontSize = 16.sp, color = Color.Gray), - selectedTextStyle = TextStyle(fontSize = 24.sp, color = Color.Blue), - dividerColor = Color.Blue + colors = PickerDefaults.colors( + dividerColor = androidx.compose.ui.graphics.Color.Blue, + selectedTextColor = androidx.compose.ui.graphics.Color.Blue, + textColor = androidx.compose.ui.graphics.Color.Gray + ) ) } @@ -344,9 +417,7 @@ fun PickerBoundedPreview() { Picker( items = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5"), state = state, - isInfinity = false, - textStyle = TextStyle(fontSize = 16.sp), - selectedTextStyle = TextStyle(fontSize = 24.sp) + isInfinity = false ) } @@ -359,9 +430,7 @@ fun Picker5VisibleItemsPreview() { items = items, state = state, startIndex = 4, - visibleItemsCount = 5, - textStyle = TextStyle(fontSize = 16.sp), - selectedTextStyle = TextStyle(fontSize = 24.sp) + visibleItemsCount = 5 ) } @@ -372,9 +441,11 @@ fun PickerSelectedBackgroundPreview() { Picker( items = listOf("Item 1", "Item 2", "Item 3"), state = state, - textStyle = TextStyle(fontSize = 16.sp, color = Color.Gray), - selectedTextStyle = TextStyle(fontSize = 24.sp, color = Color.Blue), - dividerColor = Color.Blue, - selectedItemBackgroundColor = Color.Blue.copy(alpha = 0.1f) + colors = PickerDefaults.colors( + dividerColor = androidx.compose.ui.graphics.Color.Blue, + selectedTextColor = androidx.compose.ui.graphics.Color.Blue, + textColor = androidx.compose.ui.graphics.Color.Gray, + selectedItemBackgroundColor = androidx.compose.ui.graphics.Color.Blue.copy(alpha = 0.1f) + ) ) -} \ No newline at end of file +} diff --git a/datetimepicker/src/commonMain/kotlin/com/kez/picker/PickerDefaults.kt b/datetimepicker/src/commonMain/kotlin/com/kez/picker/PickerDefaults.kt new file mode 100644 index 0000000..c4fdde8 --- /dev/null +++ b/datetimepicker/src/commonMain/kotlin/com/kez/picker/PickerDefaults.kt @@ -0,0 +1,132 @@ +package com.kez.picker + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Contains default values and factory methods for creating Picker styles. + * Follows Material3 component design patterns. + */ +object PickerDefaults { + + /** + * Default number of visible items in the picker. + * Must be an odd number for proper center alignment. + */ + const val VisibleItemsCount: Int = 3 + + /** + * Default padding around each item. + */ + val ItemPadding: PaddingValues = PaddingValues(8.dp) + + /** + * Default thickness of the dividers. + */ + val DividerThickness: Dp = 1.dp + + /** + * Default spacing between pickers in composite components (e.g., TimePicker, DatePicker). + */ + val SpacingBetweenPickers: Dp = 0.dp + + /** + * Default shape for the selected item background. + */ + val SelectedItemBackgroundShape: Shape = RoundedCornerShape(12.dp) + + /** + * Default shape for the dividers. + */ + val DividerShape: Shape = RoundedCornerShape(10.dp) + + /** + * Creates a [PickerColors] with the provided colors. + * + * @param dividerColor The color of the dividers. + * @param selectedItemBackgroundColor The background color of the selected item area. + * @param textColor The color of unselected item text. + * @param selectedTextColor The color of the selected item text. + * @return A [PickerColors] instance with the specified colors. + */ + @Composable + fun colors( + dividerColor: Color = LocalContentColor.current, + selectedItemBackgroundColor: Color = Color.Transparent, + textColor: Color = LocalContentColor.current.copy(alpha = 0.7f), + selectedTextColor: Color = LocalContentColor.current + ): PickerColors = PickerColors( + dividerColor = dividerColor, + selectedItemBackgroundColor = selectedItemBackgroundColor, + textColor = textColor, + selectedTextColor = selectedTextColor + ) + + /** + * Creates a [PickerTextStyles] with the provided text styles. + * + * @param textStyle The style of the text for unselected items. + * @param selectedTextStyle The style of the text for the selected item. + * @return A [PickerTextStyles] instance with the specified styles. + */ + @Composable + fun textStyles( + textStyle: TextStyle = LocalTextStyle.current.copy(fontSize = 16.sp), + selectedTextStyle: TextStyle = LocalTextStyle.current.copy(fontSize = 22.sp) + ): PickerTextStyles = PickerTextStyles( + textStyle = textStyle, + selectedTextStyle = selectedTextStyle + ) + + /** + * Creates the default fading edge gradient. + * + * @return A vertical [Brush] with transparent edges and opaque center. + */ + fun fadingEdgeGradient(): Brush = Brush.verticalGradient( + 0f to Color.Transparent, + 0.5f to Color.Black, + 1f to Color.Transparent + ) +} + +/** + * Represents the colors used by [Picker] and related components. + * + * @param dividerColor The color of the dividers. + * @param selectedItemBackgroundColor The background color of the selected item area. + * @param textColor The color of unselected item text. + * @param selectedTextColor The color of the selected item text. + * @see PickerDefaults.colors + */ +@Immutable +data class PickerColors( + val dividerColor: Color, + val selectedItemBackgroundColor: Color, + val textColor: Color, + val selectedTextColor: Color +) + +/** + * Represents the text styles used by [Picker] and related components. + * + * @param textStyle The style of the text for unselected items. + * @param selectedTextStyle The style of the text for the selected item. + * @see PickerDefaults.textStyles + */ +@Immutable +data class PickerTextStyles( + val textStyle: TextStyle, + val selectedTextStyle: TextStyle +) diff --git a/datetimepicker/src/commonMain/kotlin/com/kez/picker/PickerState.kt b/datetimepicker/src/commonMain/kotlin/com/kez/picker/PickerState.kt index 8b316d7..4ce6cb7 100644 --- a/datetimepicker/src/commonMain/kotlin/com/kez/picker/PickerState.kt +++ b/datetimepicker/src/commonMain/kotlin/com/kez/picker/PickerState.kt @@ -25,6 +25,9 @@ fun rememberPickerState(initialItem: T) = remember { PickerState(initialItem /** * State holder for the picker component. * + * The [selectedItem] property is read-only from external code. + * Item selection is managed internally by the [Picker] component through scrolling. + * * @param initialItem The initial selected item. */ @Stable @@ -33,8 +36,10 @@ class PickerState( ) { /** * The currently selected item. + * This value is updated internally when the user scrolls the picker. */ - var selectedItem by mutableStateOf(initialItem) + var selectedItem: T by mutableStateOf(initialItem) + internal set } /** @@ -46,8 +51,8 @@ class PickerState( */ @Composable fun rememberYearMonthPickerState( - initialYear: Int = currentDate.year, - initialMonth: Int = currentDate.month.number + initialYear: Int = currentDate().year, + initialMonth: Int = currentDate().month.number ): YearMonthPickerState { return remember(initialYear, initialMonth) { YearMonthPickerState(initialYear, initialMonth) @@ -58,6 +63,7 @@ fun rememberYearMonthPickerState( * State holder for the [com.kez.picker.date.YearMonthPicker]. * * Manages the state of the year and month pickers. + * Internal picker states are not directly accessible to prevent inconsistent state modifications. * * @param initialYear The initial year to be selected. * @param initialMonth The initial month to be selected. @@ -67,12 +73,18 @@ class YearMonthPickerState( initialYear: Int, initialMonth: Int ) { - val yearState = PickerState(initialYear) - val monthState = PickerState(initialMonth) + internal val yearState = PickerState(initialYear) + internal val monthState = PickerState(initialMonth) + /** + * The currently selected year. + */ val selectedYear: Int get() = yearState.selectedItem + /** + * The currently selected month (1-12). + */ val selectedMonth: Int get() = monthState.selectedItem } @@ -89,8 +101,8 @@ class YearMonthPickerState( */ @Composable fun rememberTimePickerState( - initialHour: Int = currentHour, - initialMinute: Int = currentMinute, + initialHour: Int = currentHour(), + initialMinute: Int = currentMinute(), initialPeriod: TimePeriod = if (initialHour >= 12) TimePeriod.PM else TimePeriod.AM, timeFormat: TimeFormat = TimeFormat.HOUR_24 ): TimePickerState { @@ -116,6 +128,7 @@ fun rememberTimePickerState( * State holder for the [TimePicker]. * * Manages the state of the hour, minute, and period (AM/PM) pickers. + * Internal picker states are not directly accessible to prevent inconsistent state modifications. * * @param initialHour The initial hour to be selected. * @param initialMinute The initial minute to be selected. @@ -129,16 +142,27 @@ class TimePickerState( initialPeriod: TimePeriod, val timeFormat: TimeFormat ) { - val hourState = PickerState(initialHour) - val minuteState = PickerState(initialMinute) - val periodState = PickerState(initialPeriod) + internal val hourState = PickerState(initialHour) + internal val minuteState = PickerState(initialMinute) + internal val periodState = PickerState(initialPeriod) + /** + * The currently selected hour. + * For 12-hour format: 1-12, for 24-hour format: 0-23. + */ val selectedHour: Int get() = hourState.selectedItem + /** + * The currently selected minute (0-59). + */ val selectedMinute: Int get() = minuteState.selectedItem + /** + * The currently selected period (AM/PM). + * Only relevant when using 12-hour format. + */ val selectedPeriod: TimePeriod get() = periodState.selectedItem -} \ No newline at end of file +} diff --git a/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/DatePicker.kt b/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/DatePicker.kt new file mode 100644 index 0000000..fd1f55b --- /dev/null +++ b/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/DatePicker.kt @@ -0,0 +1,168 @@ +package com.kez.picker.date + +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.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.kez.picker.Picker +import com.kez.picker.PickerColors +import com.kez.picker.PickerDefaults +import com.kez.picker.PickerTextStyles +import com.kez.picker.util.MONTH_RANGE +import com.kez.picker.util.YEAR_RANGE +import com.kez.picker.util.currentDate +import kotlinx.datetime.LocalDate + +/** + * A date picker component that allows selecting year, month, and day. + * + * @param modifier The modifier to be applied to the component. + * @param pickerModifier The modifier to be applied to each picker. + * @param state The state object to control the picker. + * @param startLocalDate Legacy initial date parameter. Prefer setting initial values in [state]. + * @param yearItems The list of year values to display. + * @param monthItems The list of month values to display. + * @param visibleItemsCount The number of items visible at once. + * @param colors The colors used by the picker. See [PickerDefaults.colors]. + * @param textStyles The text styles used by the picker. See [PickerDefaults.textStyles]. + * @param selectedItemBackgroundShape The shape of the selected item background. + * @param itemPadding The padding around each item. + * @param fadingEdgeGradient The gradient to use for fading edges. + * @param horizontalAlignment The horizontal alignment of items. + * @param verticalAlignment The vertical alignment of the text within items. + * @param dividerThickness The thickness of the dividers. + * @param dividerShape The shape of the dividers. + * @param spacingBetweenPickers The spacing between the pickers. + * @param isDividerVisible Whether the divider should be visible. + */ +@Composable +fun DatePicker( + modifier: Modifier = Modifier, + pickerModifier: Modifier = Modifier, + state: DatePickerState = rememberDatePickerState(), + startLocalDate: LocalDate = currentDate(), + yearItems: List = YEAR_RANGE, + monthItems: List = MONTH_RANGE, + visibleItemsCount: Int = PickerDefaults.VisibleItemsCount, + colors: PickerColors = PickerDefaults.colors(), + textStyles: PickerTextStyles = PickerDefaults.textStyles(), + selectedItemBackgroundShape: Shape = PickerDefaults.SelectedItemBackgroundShape, + itemPadding: PaddingValues = PickerDefaults.ItemPadding, + fadingEdgeGradient: Brush = PickerDefaults.fadingEdgeGradient(), + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + dividerThickness: Dp = PickerDefaults.DividerThickness, + dividerShape: Shape = PickerDefaults.DividerShape, + spacingBetweenPickers: Dp = PickerDefaults.SpacingBetweenPickers, + isDividerVisible: Boolean = true +) { + // Validate state whenever year or month changes to ensure day is within range + LaunchedEffect(state.selectedYear, state.selectedMonth) { + state.validate() + } + + Box(modifier = modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + val yearStartIndex = remember(yearItems) { yearItems.startIndexOf(state.selectedYear) } + val monthStartIndex = remember(monthItems) { monthItems.startIndexOf(state.selectedMonth) } + + val maxDay = state.maxDay + val dayItems = (1..maxDay).toList() + val dayStartIndex = remember(dayItems, state.selectedDay) { + val index = dayItems.indexOf(state.selectedDay.coerceIn(1, maxDay)) + if (index >= 0) index else 0 + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Picker( + state = state.yearState, + modifier = pickerModifier.weight(1.2f), // Give Year slightly more width + items = yearItems, + startIndex = yearStartIndex, + visibleItemsCount = visibleItemsCount, + colors = colors, + textStyles = textStyles, + selectedItemBackgroundShape = selectedItemBackgroundShape, + itemPadding = itemPadding, + fadingEdgeGradient = fadingEdgeGradient, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment, + dividerThickness = dividerThickness, + dividerShape = dividerShape, + isDividerVisible = isDividerVisible, + pickerLabel = "Year" + ) + + Picker( + state = state.monthState, + items = monthItems, + startIndex = monthStartIndex, + visibleItemsCount = visibleItemsCount, + modifier = pickerModifier.weight(0.8f), + colors = colors, + textStyles = textStyles, + selectedItemBackgroundShape = selectedItemBackgroundShape, + itemPadding = itemPadding, + fadingEdgeGradient = fadingEdgeGradient, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment, + dividerThickness = dividerThickness, + dividerShape = dividerShape, + isDividerVisible = isDividerVisible, + pickerLabel = "Month" + ) + + key(maxDay) { + Picker( + state = state.dayState, + items = dayItems, + startIndex = dayStartIndex, + visibleItemsCount = visibleItemsCount, + modifier = pickerModifier.weight(0.8f), + colors = colors, + textStyles = textStyles, + selectedItemBackgroundShape = selectedItemBackgroundShape, + itemPadding = itemPadding, + isInfinity = false, + fadingEdgeGradient = fadingEdgeGradient, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment, + dividerThickness = dividerThickness, + dividerShape = dividerShape, + isDividerVisible = isDividerVisible, + pickerLabel = "Day" + ) + } + } + } + } +} + +private fun List.startIndexOf(item: T): Int = + indexOf(item).takeIf { it >= 0 } ?: 0 + +@Preview(name = "Default", group = "DatePicker", showBackground = true) +@Composable +fun DatePickerPreview() { + DatePicker() +} diff --git a/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/DatePickerState.kt b/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/DatePickerState.kt new file mode 100644 index 0000000..8d630b0 --- /dev/null +++ b/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/DatePickerState.kt @@ -0,0 +1,114 @@ +package com.kez.picker.date + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import com.kez.picker.PickerState +import com.kez.picker.util.currentDate +import kotlinx.datetime.number + +/** + * Creates and remembers a [DatePickerState]. + * + * @param initialYear The initial year to be selected. Defaults to the current year. + * @param initialMonth The initial month to be selected. Defaults to the current month. + * @param initialDay The initial day to be selected. Defaults to the current day. + * @return A [DatePickerState] initialized with the given date values. + */ +@Composable +fun rememberDatePickerState( + initialYear: Int = currentDate().year, + initialMonth: Int = currentDate().month.number, + initialDay: Int = currentDate().day +): DatePickerState { + return remember(initialYear, initialMonth, initialDay) { + DatePickerState(initialYear, initialMonth, initialDay) + } +} + +/** + * State holder for the DatePicker. + * + * Manages the state of the year, month, and day pickers. + * Automatically adjusts the selected day if it exceeds the maximum valid day for the new year/month. + * Internal picker states are not directly accessible to prevent inconsistent state modifications. + * + * @param initialYear The initial year to be selected. + * @param initialMonth The initial month to be selected. + * @param initialDay The initial day to be selected. + */ +@Stable +class DatePickerState( + initialYear: Int, + initialMonth: Int, + initialDay: Int +) { + internal val yearState = PickerState(initialYear) + internal val monthState = PickerState(initialMonth) + internal val dayState = PickerState(initialDay) + + /** + * The currently selected year. + */ + val selectedYear: Int + get() = yearState.selectedItem + + /** + * The currently selected month (1-12). + */ + val selectedMonth: Int + get() = monthState.selectedItem + + /** + * The currently selected day (1-31). + */ + val selectedDay: Int + get() = dayState.selectedItem + + /** + * The currently valid maximum day for the selected year and month. + * Calculated dynamically based on the selected year and month. + */ + val maxDay: Int + get() = daysInMonth(selectedYear, selectedMonth) + + init { + require(initialMonth in 1..12) { + "initialMonth must be in range [1, 12], but was $initialMonth" + } + require(initialDay >= 1) { + "initialDay must be greater than or equal to 1, but was $initialDay" + } + val initialMaxDay = daysInMonth(initialYear, initialMonth) + if (initialDay > initialMaxDay) { + dayState.selectedItem = initialMaxDay + } + } + + /** + * Validates and adjusts the selected day if it exceeds the maximum valid day + * for the currently selected year and month. + * + * This function should be called when the year or month changes to ensure + * the day remains valid (e.g., Feb 30 -> Feb 28/29). + */ + internal fun validate() { + val currentMax = maxDay + if (dayState.selectedItem > currentMax) { + dayState.selectedItem = currentMax + } + } + + private fun daysInMonth(year: Int, month: Int): Int { + return when (month) { + 1, 3, 5, 7, 8, 10, 12 -> 31 + 4, 6, 9, 11 -> 30 + 2 -> if (isLeapYear(year)) 29 else 28 + else -> 31 // Should not happen with 1-12 range + } + } + + private fun isLeapYear(year: Int): Boolean { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + } +} diff --git a/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/YearMonthPicker.kt b/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/YearMonthPicker.kt index 07ec07d..40fc4f3 100644 --- a/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/YearMonthPicker.kt +++ b/datetimepicker/src/commonMain/kotlin/com/kez/picker/date/YearMonthPicker.kt @@ -6,8 +6,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -15,12 +13,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -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.kez.picker.Picker +import com.kez.picker.PickerColors +import com.kez.picker.PickerDefaults +import com.kez.picker.PickerTextStyles import com.kez.picker.YearMonthPickerState import com.kez.picker.rememberYearMonthPickerState import com.kez.picker.util.MONTH_RANGE @@ -34,18 +33,15 @@ import kotlinx.datetime.number * * @param modifier The modifier to be applied to the component. * @param pickerModifier The modifier to be applied to each picker. - * @param yearPickerState The state for the year picker. - * @param monthPickerState The state for the month picker. - * @param startLocalDate The initial date to display. + * @param state The state object to control the picker. + * @param startLocalDate Legacy initial date parameter. Prefer setting initial values in [state]. * @param yearItems The list of year values to display. * @param monthItems The list of month values to display. * @param visibleItemsCount The number of items visible at once. - * @param itemPadding The padding around each item. - * @param textStyle The style of the text for unselected items. - * @param selectedTextStyle The style of the text for the selected item. - * @param dividerColor The color of the dividers. - * @param selectedItemBackgroundColor The background color of each individual picker's selected item area. + * @param colors The colors used by the picker. See [PickerDefaults.colors]. + * @param textStyles The text styles used by the picker. See [PickerDefaults.textStyles]. * @param selectedItemBackgroundShape The shape of the selected item background. + * @param itemPadding The padding around each item. * @param fadingEdgeGradient The gradient to use for fading edges. * @param horizontalAlignment The horizontal alignment of items. * @param verticalAlignment The vertical alignment of the text within items. @@ -59,26 +55,20 @@ fun YearMonthPicker( modifier: Modifier = Modifier, pickerModifier: Modifier = Modifier, state: YearMonthPickerState = rememberYearMonthPickerState(), - startLocalDate: LocalDate = currentDate, + startLocalDate: LocalDate = currentDate(), yearItems: List = YEAR_RANGE, monthItems: List = MONTH_RANGE, - visibleItemsCount: Int = 3, - itemPadding: PaddingValues = PaddingValues(8.dp), - textStyle: TextStyle = TextStyle(fontSize = 16.sp), - selectedTextStyle: TextStyle = TextStyle(fontSize = 24.sp), - dividerColor: Color = LocalContentColor.current, - selectedItemBackgroundColor: Color = Color.Transparent, - selectedItemBackgroundShape: Shape = RoundedCornerShape(12.dp), - fadingEdgeGradient: Brush = Brush.verticalGradient( - 0f to Color.Transparent, - 0.5f to Color.Black, - 1f to Color.Transparent - ), + visibleItemsCount: Int = PickerDefaults.VisibleItemsCount, + colors: PickerColors = PickerDefaults.colors(), + textStyles: PickerTextStyles = PickerDefaults.textStyles(), + selectedItemBackgroundShape: Shape = PickerDefaults.SelectedItemBackgroundShape, + itemPadding: PaddingValues = PickerDefaults.ItemPadding, + fadingEdgeGradient: Brush = PickerDefaults.fadingEdgeGradient(), horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, - dividerThickness: Dp = 2.dp, - dividerShape: Shape = RoundedCornerShape(10.dp), - spacingBetweenPickers: Dp = 20.dp, + dividerThickness: Dp = PickerDefaults.DividerThickness, + dividerShape: Shape = PickerDefaults.DividerShape, + spacingBetweenPickers: Dp = PickerDefaults.SpacingBetweenPickers, isDividerVisible: Boolean = true ) { Box(modifier = modifier) { @@ -88,11 +78,11 @@ fun YearMonthPicker( modifier = Modifier.fillMaxWidth() ) { - val yearStartIndex = remember { - yearItems.indexOf(startLocalDate.year) + val yearStartIndex = remember(yearItems) { + yearItems.startIndexOf(state.selectedYear) } - val monthStartIndex = remember { - monthItems.indexOf(startLocalDate.month.number) + val monthStartIndex = remember(monthItems) { + monthItems.startIndexOf(state.selectedMonth) } Row( @@ -108,18 +98,17 @@ fun YearMonthPicker( items = yearItems, startIndex = yearStartIndex, visibleItemsCount = visibleItemsCount, - textStyle = textStyle, - selectedTextStyle = selectedTextStyle, - dividerColor = dividerColor, - selectedItemBackgroundColor = selectedItemBackgroundColor, + colors = colors, + textStyles = textStyles, selectedItemBackgroundShape = selectedItemBackgroundShape, itemPadding = itemPadding, fadingEdgeGradient = fadingEdgeGradient, horizontalAlignment = horizontalAlignment, - itemTextAlignment = verticalAlignment, + verticalAlignment = verticalAlignment, dividerThickness = dividerThickness, dividerShape = dividerShape, isDividerVisible = isDividerVisible, + pickerLabel = "Year" ) Picker( state = state.monthState, @@ -127,24 +116,26 @@ fun YearMonthPicker( startIndex = monthStartIndex, visibleItemsCount = visibleItemsCount, modifier = pickerModifier.weight(1f), - textStyle = textStyle, - selectedTextStyle = selectedTextStyle, - dividerColor = dividerColor, - selectedItemBackgroundColor = selectedItemBackgroundColor, + colors = colors, + textStyles = textStyles, selectedItemBackgroundShape = selectedItemBackgroundShape, itemPadding = itemPadding, fadingEdgeGradient = fadingEdgeGradient, horizontalAlignment = horizontalAlignment, - itemTextAlignment = verticalAlignment, + verticalAlignment = verticalAlignment, dividerThickness = dividerThickness, dividerShape = dividerShape, isDividerVisible = isDividerVisible, + pickerLabel = "Month" ) } } } } +private fun List.startIndexOf(item: T): Int = + indexOf(item).takeIf { it >= 0 } ?: 0 + @Preview(name = "Default", group = "YearMonthPicker - Basic", showBackground = true) @Composable fun YearMonthPickerPreview() { @@ -163,9 +154,11 @@ fun YearMonthPickerNoDividerPreview() { @Composable fun YearMonthPickerCustomColorsPreview() { YearMonthPicker( - textStyle = TextStyle(fontSize = 16.sp, color = Color.Gray), - selectedTextStyle = TextStyle(fontSize = 24.sp, color = Color(0xFF03DAC5)), - dividerColor = Color(0xFF03DAC5) + colors = PickerDefaults.colors( + textColor = Color.Gray, + selectedTextColor = Color(0xFF03DAC5), + dividerColor = Color(0xFF03DAC5) + ) ) } @@ -173,8 +166,10 @@ fun YearMonthPickerCustomColorsPreview() { @Composable fun YearMonthPickerLargeTextPreview() { YearMonthPicker( - textStyle = TextStyle(fontSize = 18.sp), - selectedTextStyle = TextStyle(fontSize = 28.sp), + textStyles = PickerDefaults.textStyles( + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), + selectedTextStyle = androidx.compose.ui.text.TextStyle(fontSize = 28.sp) + ), visibleItemsCount = 5 ) -} \ No newline at end of file +} diff --git a/datetimepicker/src/commonMain/kotlin/com/kez/picker/time/TimePicker.kt b/datetimepicker/src/commonMain/kotlin/com/kez/picker/time/TimePicker.kt index 75a00ec..230f103 100644 --- a/datetimepicker/src/commonMain/kotlin/com/kez/picker/time/TimePicker.kt +++ b/datetimepicker/src/commonMain/kotlin/com/kez/picker/time/TimePicker.kt @@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -17,12 +15,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -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.kez.picker.Picker +import com.kez.picker.PickerColors +import com.kez.picker.PickerDefaults +import com.kez.picker.PickerTextStyles import com.kez.picker.TimePickerState import com.kez.picker.rememberTimePickerState import com.kez.picker.util.HOUR12_RANGE @@ -39,17 +38,15 @@ import kotlinx.datetime.LocalDateTime * @param modifier The modifier to be applied to the component. * @param pickerModifier The modifier to be applied to each picker. * @param state The state object to control the picker. - * @param startTime The initial time to display. + * @param startTime Legacy initial time parameter. Prefer setting initial values in [state]. * @param minuteItems The list of minute values to display. * @param hourItems The list of hour values to display. * @param periodItems The list of period values to display. * @param visibleItemsCount The number of items visible at once. - * @param itemPadding The padding around each item. - * @param textStyle The style of the text for unselected items. - * @param selectedTextStyle The style of the text for the selected item. - * @param dividerColor The color of the dividers. - * @param selectedItemBackgroundColor The background color of the selected item area. + * @param colors The colors used by the picker. See [PickerDefaults.colors]. + * @param textStyles The text styles used by the picker. See [PickerDefaults.textStyles]. * @param selectedItemBackgroundShape The shape of the selected item background. + * @param itemPadding The padding around each item. * @param fadingEdgeGradient The gradient to use for fading edges. * @param horizontalAlignment The horizontal alignment of items. * @param verticalAlignment The vertical alignment of the text within items. @@ -63,30 +60,24 @@ fun TimePicker( modifier: Modifier = Modifier, pickerModifier: Modifier = Modifier, state: TimePickerState = rememberTimePickerState(), - startTime: LocalDateTime = currentDateTime, + startTime: LocalDateTime = currentDateTime(), minuteItems: List = MINUTE_RANGE, hourItems: List = when (state.timeFormat) { TimeFormat.HOUR_12 -> HOUR12_RANGE TimeFormat.HOUR_24 -> HOUR24_RANGE }, periodItems: List = TimePeriod.entries, - visibleItemsCount: Int = 3, - itemPadding: PaddingValues = PaddingValues(8.dp), - textStyle: TextStyle = TextStyle(fontSize = 16.sp), - selectedTextStyle: TextStyle = TextStyle(fontSize = 22.sp), - dividerColor: Color = LocalContentColor.current, - selectedItemBackgroundColor: Color = Color.Transparent, - selectedItemBackgroundShape: Shape = RoundedCornerShape(12.dp), - fadingEdgeGradient: Brush = Brush.verticalGradient( - 0f to Color.Transparent, - 0.5f to Color.Black, - 1f to Color.Transparent - ), + visibleItemsCount: Int = PickerDefaults.VisibleItemsCount, + colors: PickerColors = PickerDefaults.colors(), + textStyles: PickerTextStyles = PickerDefaults.textStyles(), + selectedItemBackgroundShape: Shape = PickerDefaults.SelectedItemBackgroundShape, + itemPadding: PaddingValues = PickerDefaults.ItemPadding, + fadingEdgeGradient: Brush = PickerDefaults.fadingEdgeGradient(), horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, - dividerThickness: Dp = 1.dp, - dividerShape: Shape = RoundedCornerShape(10.dp), - spacingBetweenPickers: Dp = 20.dp, + dividerThickness: Dp = PickerDefaults.DividerThickness, + dividerShape: Shape = PickerDefaults.DividerShape, + spacingBetweenPickers: Dp = PickerDefaults.SpacingBetweenPickers, isDividerVisible: Boolean = true ) { Box(modifier = modifier) { @@ -96,25 +87,16 @@ fun TimePicker( modifier = Modifier.fillMaxWidth() ) { - val minuteStartIndex = remember { - minuteItems.indexOf(startTime.minute) + val minuteStartIndex = remember(minuteItems) { + minuteItems.startIndexOf(state.selectedMinute) } - val hourStartIndex = remember { - val startHour = when (state.timeFormat) { - TimeFormat.HOUR_12 -> { - val hour = startTime.hour % 12 - if (hour == 0) 12 else hour - } - - TimeFormat.HOUR_24 -> startTime.hour - } - hourItems.indexOf(startHour) + val hourStartIndex = remember(hourItems) { + hourItems.startIndexOf(state.selectedHour) } - val periodStartIndex = remember { - val period = if (startTime.hour >= 12) TimePeriod.PM else TimePeriod.AM - periodItems.indexOf(period) + val periodStartIndex = remember(periodItems) { + periodItems.startIndexOf(state.selectedPeriod) } Row( @@ -128,20 +110,19 @@ fun TimePicker( items = periodItems, visibleItemsCount = visibleItemsCount, modifier = pickerModifier.weight(1f), - textStyle = textStyle, - selectedTextStyle = selectedTextStyle, - dividerColor = dividerColor, - selectedItemBackgroundColor = selectedItemBackgroundColor, + colors = colors, + textStyles = textStyles, selectedItemBackgroundShape = selectedItemBackgroundShape, itemPadding = itemPadding, startIndex = periodStartIndex, fadingEdgeGradient = fadingEdgeGradient, horizontalAlignment = horizontalAlignment, - itemTextAlignment = verticalAlignment, + verticalAlignment = verticalAlignment, dividerThickness = dividerThickness, dividerShape = dividerShape, isDividerVisible = isDividerVisible, - isInfinity = false + isInfinity = false, + pickerLabel = "Period" ) Spacer(modifier = Modifier.width(spacingBetweenPickers)) } @@ -151,18 +132,17 @@ fun TimePicker( items = hourItems, startIndex = hourStartIndex, visibleItemsCount = visibleItemsCount, - textStyle = textStyle, - selectedTextStyle = selectedTextStyle, - dividerColor = dividerColor, - selectedItemBackgroundColor = selectedItemBackgroundColor, + colors = colors, + textStyles = textStyles, selectedItemBackgroundShape = selectedItemBackgroundShape, itemPadding = itemPadding, fadingEdgeGradient = fadingEdgeGradient, horizontalAlignment = horizontalAlignment, - itemTextAlignment = verticalAlignment, + verticalAlignment = verticalAlignment, dividerThickness = dividerThickness, dividerShape = dividerShape, - isDividerVisible = isDividerVisible + isDividerVisible = isDividerVisible, + pickerLabel = "Hour" ) Spacer(modifier = Modifier.width(spacingBetweenPickers)) Picker( @@ -171,24 +151,26 @@ fun TimePicker( startIndex = minuteStartIndex, visibleItemsCount = visibleItemsCount, modifier = pickerModifier.weight(1f), - textStyle = textStyle, - selectedTextStyle = selectedTextStyle, - dividerColor = dividerColor, - selectedItemBackgroundColor = selectedItemBackgroundColor, + colors = colors, + textStyles = textStyles, selectedItemBackgroundShape = selectedItemBackgroundShape, itemPadding = itemPadding, fadingEdgeGradient = fadingEdgeGradient, horizontalAlignment = horizontalAlignment, - itemTextAlignment = verticalAlignment, + verticalAlignment = verticalAlignment, dividerThickness = dividerThickness, dividerShape = dividerShape, - isDividerVisible = isDividerVisible + isDividerVisible = isDividerVisible, + pickerLabel = "Minute" ) } } } } +private fun List.startIndexOf(item: T): Int = + indexOf(item).takeIf { it >= 0 } ?: 0 + @Preview(name = "24-Hour Format", group = "TimePicker - Formats", showBackground = true) @Composable fun TimePickerPreview24Hour() { @@ -219,9 +201,11 @@ fun TimePickerNoDividerPreview() { fun TimePickerCustomColorsPreview() { TimePicker( state = rememberTimePickerState(timeFormat = TimeFormat.HOUR_12), - textStyle = TextStyle(fontSize = 16.sp, color = Color.Gray), - selectedTextStyle = TextStyle(fontSize = 22.sp, color = Color(0xFF6200EE)), - dividerColor = Color(0xFF6200EE) + colors = PickerDefaults.colors( + textColor = Color.Gray, + selectedTextColor = Color(0xFF6200EE), + dividerColor = Color(0xFF6200EE) + ) ) } @@ -230,8 +214,10 @@ fun TimePickerCustomColorsPreview() { fun TimePickerLargeTextPreview() { TimePicker( state = rememberTimePickerState(timeFormat = TimeFormat.HOUR_24), - textStyle = TextStyle(fontSize = 20.sp), - selectedTextStyle = TextStyle(fontSize = 28.sp), + textStyles = PickerDefaults.textStyles( + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 20.sp), + selectedTextStyle = androidx.compose.ui.text.TextStyle(fontSize = 28.sp) + ), visibleItemsCount = 5 ) -} \ No newline at end of file +} diff --git a/datetimepicker/src/commonMain/kotlin/com/kez/picker/util/TimeCalculation.kt b/datetimepicker/src/commonMain/kotlin/com/kez/picker/util/TimeCalculation.kt index 49977dc..dac4146 100644 --- a/datetimepicker/src/commonMain/kotlin/com/kez/picker/util/TimeCalculation.kt +++ b/datetimepicker/src/commonMain/kotlin/com/kez/picker/util/TimeCalculation.kt @@ -29,11 +29,12 @@ fun calculateTime( TimeFormat.HOUR_24 -> hour } + val now = currentDateTime() return LocalDateTime( - year = currentYear, - month = currentMonth, - day = currentDate.day, + year = now.year, + month = now.month, + day = now.date.day, hour = adjustHour.coerceIn(0, 23), minute = minute.coerceIn(0, 59) ) -} \ No newline at end of file +} diff --git a/datetimepicker/src/commonMain/kotlin/com/kez/picker/util/TimeUtil.kt b/datetimepicker/src/commonMain/kotlin/com/kez/picker/util/TimeUtil.kt index 325abad..c531ed1 100644 --- a/datetimepicker/src/commonMain/kotlin/com/kez/picker/util/TimeUtil.kt +++ b/datetimepicker/src/commonMain/kotlin/com/kez/picker/util/TimeUtil.kt @@ -3,6 +3,8 @@ package com.kez.picker.util import kotlin.time.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime @@ -33,34 +35,49 @@ val HOUR12_RANGE = (1..12).toList() val MINUTE_RANGE = (0..59).toList() /** - * Current date and time. + * Returns the current date and time. + * This function is called each time to get the actual current time, + * preventing stale time values in long-running applications. + * + * @return The current [LocalDateTime] in the system's default timezone. */ -val currentDateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) +fun currentDateTime(): LocalDateTime = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) /** - * Current date. + * Returns the current date. + * + * @return The current [LocalDate]. */ -val currentDate = currentDateTime.date +fun currentDate(): LocalDate = currentDateTime().date /** - * Current year. + * Returns the current year. + * + * @return The current year as an [Int]. */ -val currentYear = currentDateTime.year +fun currentYear(): Int = currentDateTime().year /** - * Current month number (1-12). + * Returns the current month number (1-12). + * + * @return The current month number as an [Int]. */ -val currentMonth = currentDateTime.month.number +fun currentMonth(): Int = currentDateTime().month.number /** - * Current minute (0-59). + * Returns the current minute (0-59). + * + * @return The current minute as an [Int]. */ -val currentMinute = currentDateTime.minute +fun currentMinute(): Int = currentDateTime().minute /** - * Current hour (0-23). + * Returns the current hour (0-23). + * + * @return The current hour as an [Int]. */ -val currentHour = currentDateTime.hour +fun currentHour(): Int = currentDateTime().hour /** * Time format for time picker. @@ -70,7 +87,7 @@ enum class TimeFormat { * 12-hour format (AM/PM). */ HOUR_12, - + /** * 24-hour format. */ @@ -85,9 +102,9 @@ enum class TimePeriod { * AM period (Ante Meridiem). */ AM, - + /** * PM period (Post Meridiem). */ PM -} \ No newline at end of file +} diff --git a/datetimepicker/src/commonTest/kotlin/com/kez/picker/PickerStateTest.kt b/datetimepicker/src/commonTest/kotlin/com/kez/picker/PickerStateTest.kt new file mode 100644 index 0000000..262362e --- /dev/null +++ b/datetimepicker/src/commonTest/kotlin/com/kez/picker/PickerStateTest.kt @@ -0,0 +1,58 @@ +package com.kez.picker + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * Unit tests for [PickerState] class. + * + * Tests cover: + * - Initial value setting + * - State updates + * - Generic type support + */ +class PickerStateTest { + + @Test + fun pickerState_initialValue_isCorrect() { + val state = PickerState("Initial") + assertEquals("Initial", state.selectedItem) + } + + @Test + fun pickerState_intInitialValue_isCorrect() { + val state = PickerState(42) + assertEquals(42, state.selectedItem) + } + + @Test + fun pickerState_nullableInitialValue_isCorrect() { + val state = PickerState(null) + assertEquals(null, state.selectedItem) + } + + @Test + fun pickerState_customObjectInitialValue_isCorrect() { + data class CustomItem(val id: Int, val name: String) + val item = CustomItem(1, "Test") + val state = PickerState(item) + assertEquals(item, state.selectedItem) + } + + @Test + fun pickerState_differentInstances_areIndependent() { + val state1 = PickerState("State1") + val state2 = PickerState("State2") + + assertNotEquals(state1.selectedItem, state2.selectedItem) + } + + @Test + fun pickerState_sameInitialValue_areEqual() { + val state1 = PickerState(100) + val state2 = PickerState(100) + + assertEquals(state1.selectedItem, state2.selectedItem) + } +} diff --git a/datetimepicker/src/commonTest/kotlin/com/kez/picker/PickerUtilsTest.kt b/datetimepicker/src/commonTest/kotlin/com/kez/picker/PickerUtilsTest.kt new file mode 100644 index 0000000..549119a --- /dev/null +++ b/datetimepicker/src/commonTest/kotlin/com/kez/picker/PickerUtilsTest.kt @@ -0,0 +1,155 @@ +package com.kez.picker + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Unit tests for Picker utility functions and calculations. + * + * Tests cover: + * - Index calculations for infinite scroll + * - Modulo operations for cyclic behavior + * - Edge case handling + */ +class PickerUtilsTest { + + // ==================== Modulo Index Calculation Tests ==================== + + @Test + fun modIndex_positiveValues_calculatesCorrectly() { + val items = listOf("A", "B", "C") + val size = items.size + + assertEquals(0, 0.mod(size)) + assertEquals(1, 1.mod(size)) + assertEquals(2, 2.mod(size)) + assertEquals(0, 3.mod(size)) + assertEquals(1, 4.mod(size)) + } + + @Test + fun modIndex_largeValues_calculatesCorrectly() { + val items = listOf(1, 2, 3, 4, 5) + val size = items.size + + assertEquals(0, 1000.mod(size)) + assertEquals(1, 1001.mod(size)) + assertEquals(4, 999.mod(size)) + } + + @Test + fun modIndex_negativeValues_handledCorrectly() { + val size = 5 + + // Kotlin's mod() handles negative numbers correctly + assertEquals(4, (-1).mod(size)) + assertEquals(3, (-2).mod(size)) + assertEquals(0, (-5).mod(size)) + } + + // ==================== Visible Items Count Validation Tests ==================== + + @Test + fun visibleItemsCount_oddNumbers_areValid() { + val validCounts = listOf(1, 3, 5, 7, 9, 11) + + for (count in validCounts) { + assertTrue(count > 0 && count % 2 == 1, "Count $count should be a positive odd number") + } + } + + @Test + fun visibleItemsCount_evenNumbers_areInvalid() { + val invalidCounts = listOf(2, 4, 6, 8, 10) + + for (count in invalidCounts) { + assertTrue(count % 2 == 0, "Count $count should be an even number") + } + } + + // ==================== List Middle Calculation Tests ==================== + + @Test + fun listMiddle_calculatesCorrectly() { + assertEquals(0, 1 / 2) + assertEquals(1, 3 / 2) + assertEquals(2, 5 / 2) + assertEquals(3, 7 / 2) + } + + // ==================== Infinite Scroll Multiplier Tests ==================== + + @Test + fun infiniteScrollMultiplier_producesReasonableSize() { + val items = listOf("A", "B", "C") + val multiplier = 1000 + val listScrollCount = items.size * multiplier + + assertEquals(3000, listScrollCount) + assertTrue(listScrollCount < Int.MAX_VALUE) + } + + @Test + fun infiniteScrollMultiplier_centerCalculation_isCorrect() { + val items = listOf("A", "B", "C", "D", "E") + val multiplier = 1000 + val listScrollCount = items.size * multiplier + val listScrollMiddle = listScrollCount / 2 + + assertEquals(2500, listScrollMiddle) + } + + // ==================== Start Index Calculation Tests ==================== + + @Test + fun startIndex_infinityMode_calculatesCorrectly() { + val items = listOf("A", "B", "C", "D", "E") + val itemSize = items.size + val multiplier = 1000 + val listScrollCount = itemSize * multiplier + val listScrollMiddle = listScrollCount / 2 + val visibleItemsMiddle = 1 // for visibleItemsCount = 3 + val startIndex = 2 + + val expectedStartIndex = + listScrollMiddle - listScrollMiddle % itemSize - visibleItemsMiddle + startIndex + + // Should position us near the middle but at the correct item + assertTrue(expectedStartIndex > 0) + assertTrue(expectedStartIndex < listScrollCount) + } + + @Test + fun startIndex_boundedMode_calculatesCorrectly() { + val startIndex = 2 + val expectedBoundedStart = startIndex + + assertEquals(2, expectedBoundedStart) + } + + @Test + fun boundedMode_paddingMatchesVisibleItemsMiddle() { + val visibleItemsMiddle = 2 // for visibleItemsCount = 5 + val items = listOf("A", "B", "C") + val adjustedItems = List(visibleItemsMiddle) { null } + items + List(visibleItemsMiddle) { null } + + assertEquals(null, adjustedItems[0]) + assertEquals(null, adjustedItems[1]) + assertEquals("A", adjustedItems[visibleItemsMiddle]) + assertEquals("C", adjustedItems[visibleItemsMiddle + items.lastIndex]) + } + + // ==================== Fraction Calculation Tests ==================== + + @Test + fun fraction_coerceInRange_worksCorrectly() { + val testValues = listOf(-2f, -1f, -0.5f, 0f, 0.5f, 1f, 2f) + val expectedResults = listOf(1f, 1f, 0.5f, 0f, 0.5f, 1f, 1f) + + testValues.zip(expectedResults).forEach { (input, expected) -> + val result = kotlin.math.abs(input.coerceIn(-1f, 1f)) + assertEquals(expected, result, 0.001f, "For input $input, expected $expected but got $result") + } + } +} diff --git a/datetimepicker/src/commonTest/kotlin/com/kez/picker/TimePickerStateTest.kt b/datetimepicker/src/commonTest/kotlin/com/kez/picker/TimePickerStateTest.kt new file mode 100644 index 0000000..4716e7e --- /dev/null +++ b/datetimepicker/src/commonTest/kotlin/com/kez/picker/TimePickerStateTest.kt @@ -0,0 +1,203 @@ +package com.kez.picker + +import com.kez.picker.util.TimeFormat +import com.kez.picker.util.TimePeriod +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Unit tests for [TimePickerState] class. + * + * Tests cover: + * - 24-hour format initialization + * - 12-hour format initialization with AM/PM + * - Boundary conditions for hours and minutes + * - Period (AM/PM) handling + */ +class TimePickerStateTest { + + // ==================== 24-hour Format Tests ==================== + + @Test + fun timePickerState_24HourFormat_initialValues_areCorrect() { + val state = TimePickerState( + initialHour = 14, + initialMinute = 30, + initialPeriod = TimePeriod.PM, + timeFormat = TimeFormat.HOUR_24 + ) + + assertEquals(14, state.selectedHour) + assertEquals(30, state.selectedMinute) + assertEquals(TimeFormat.HOUR_24, state.timeFormat) + } + + @Test + fun timePickerState_24HourFormat_midnight_isCorrect() { + val state = TimePickerState( + initialHour = 0, + initialMinute = 0, + initialPeriod = TimePeriod.AM, + timeFormat = TimeFormat.HOUR_24 + ) + + assertEquals(0, state.selectedHour) + assertEquals(0, state.selectedMinute) + } + + @Test + fun timePickerState_24HourFormat_noon_isCorrect() { + val state = TimePickerState( + initialHour = 12, + initialMinute = 0, + initialPeriod = TimePeriod.PM, + timeFormat = TimeFormat.HOUR_24 + ) + + assertEquals(12, state.selectedHour) + assertEquals(0, state.selectedMinute) + } + + @Test + fun timePickerState_24HourFormat_endOfDay_isCorrect() { + val state = TimePickerState( + initialHour = 23, + initialMinute = 59, + initialPeriod = TimePeriod.PM, + timeFormat = TimeFormat.HOUR_24 + ) + + assertEquals(23, state.selectedHour) + assertEquals(59, state.selectedMinute) + } + + // ==================== 12-hour Format Tests ==================== + + @Test + fun timePickerState_12HourFormat_morning_isCorrect() { + val state = TimePickerState( + initialHour = 9, + initialMinute = 30, + initialPeriod = TimePeriod.AM, + timeFormat = TimeFormat.HOUR_12 + ) + + assertEquals(9, state.selectedHour) + assertEquals(30, state.selectedMinute) + assertEquals(TimePeriod.AM, state.selectedPeriod) + } + + @Test + fun timePickerState_12HourFormat_afternoon_isCorrect() { + val state = TimePickerState( + initialHour = 3, + initialMinute = 45, + initialPeriod = TimePeriod.PM, + timeFormat = TimeFormat.HOUR_12 + ) + + assertEquals(3, state.selectedHour) + assertEquals(45, state.selectedMinute) + assertEquals(TimePeriod.PM, state.selectedPeriod) + } + + @Test + fun timePickerState_12HourFormat_12AM_midnight_isCorrect() { + val state = TimePickerState( + initialHour = 12, + initialMinute = 0, + initialPeriod = TimePeriod.AM, + timeFormat = TimeFormat.HOUR_12 + ) + + assertEquals(12, state.selectedHour) + assertEquals(0, state.selectedMinute) + assertEquals(TimePeriod.AM, state.selectedPeriod) + } + + @Test + fun timePickerState_12HourFormat_12PM_noon_isCorrect() { + val state = TimePickerState( + initialHour = 12, + initialMinute = 0, + initialPeriod = TimePeriod.PM, + timeFormat = TimeFormat.HOUR_12 + ) + + assertEquals(12, state.selectedHour) + assertEquals(0, state.selectedMinute) + assertEquals(TimePeriod.PM, state.selectedPeriod) + } + + // ==================== Minute Boundary Tests ==================== + + @Test + fun timePickerState_minuteZero_isCorrect() { + val state = TimePickerState( + initialHour = 10, + initialMinute = 0, + initialPeriod = TimePeriod.AM, + timeFormat = TimeFormat.HOUR_24 + ) + + assertEquals(0, state.selectedMinute) + } + + @Test + fun timePickerState_minute59_isCorrect() { + val state = TimePickerState( + initialHour = 10, + initialMinute = 59, + initialPeriod = TimePeriod.AM, + timeFormat = TimeFormat.HOUR_24 + ) + + assertEquals(59, state.selectedMinute) + } + + // ==================== State Independence Tests ==================== + + @Test + fun timePickerState_multipleInstances_areIndependent() { + val state1 = TimePickerState( + initialHour = 10, + initialMinute = 30, + initialPeriod = TimePeriod.AM, + timeFormat = TimeFormat.HOUR_24 + ) + + val state2 = TimePickerState( + initialHour = 15, + initialMinute = 45, + initialPeriod = TimePeriod.PM, + timeFormat = TimeFormat.HOUR_24 + ) + + assertEquals(10, state1.selectedHour) + assertEquals(15, state2.selectedHour) + assertEquals(30, state1.selectedMinute) + assertEquals(45, state2.selectedMinute) + } + + // ==================== TimeFormat Tests ==================== + + @Test + fun timePickerState_timeFormatProperty_isCorrect() { + val state24 = TimePickerState( + initialHour = 10, + initialMinute = 0, + initialPeriod = TimePeriod.AM, + timeFormat = TimeFormat.HOUR_24 + ) + + val state12 = TimePickerState( + initialHour = 10, + initialMinute = 0, + initialPeriod = TimePeriod.AM, + timeFormat = TimeFormat.HOUR_12 + ) + + assertEquals(TimeFormat.HOUR_24, state24.timeFormat) + assertEquals(TimeFormat.HOUR_12, state12.timeFormat) + } +} diff --git a/datetimepicker/src/commonTest/kotlin/com/kez/picker/YearMonthPickerStateTest.kt b/datetimepicker/src/commonTest/kotlin/com/kez/picker/YearMonthPickerStateTest.kt new file mode 100644 index 0000000..1b7a3d3 --- /dev/null +++ b/datetimepicker/src/commonTest/kotlin/com/kez/picker/YearMonthPickerStateTest.kt @@ -0,0 +1,193 @@ +package com.kez.picker + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * Unit tests for [YearMonthPickerState] class. + * + * Tests cover: + * - Initial value setting for year and month + * - Boundary conditions for months (1-12) + * - Various year values + * - State independence + */ +class YearMonthPickerStateTest { + + // ==================== Initial Value Tests ==================== + + @Test + fun yearMonthPickerState_initialValues_areCorrect() { + val state = YearMonthPickerState( + initialYear = 2024, + initialMonth = 6 + ) + + assertEquals(2024, state.selectedYear) + assertEquals(6, state.selectedMonth) + } + + @Test + fun yearMonthPickerState_januaryFirstMonth_isCorrect() { + val state = YearMonthPickerState( + initialYear = 2024, + initialMonth = 1 + ) + + assertEquals(1, state.selectedMonth) + } + + @Test + fun yearMonthPickerState_decemberLastMonth_isCorrect() { + val state = YearMonthPickerState( + initialYear = 2024, + initialMonth = 12 + ) + + assertEquals(12, state.selectedMonth) + } + + // ==================== Year Boundary Tests ==================== + + @Test + fun yearMonthPickerState_year1900_isCorrect() { + val state = YearMonthPickerState( + initialYear = 1900, + initialMonth = 1 + ) + + assertEquals(1900, state.selectedYear) + } + + @Test + fun yearMonthPickerState_year2100_isCorrect() { + val state = YearMonthPickerState( + initialYear = 2100, + initialMonth = 12 + ) + + assertEquals(2100, state.selectedYear) + } + + @Test + fun yearMonthPickerState_currentYear_isCorrect() { + val state = YearMonthPickerState( + initialYear = 2025, + initialMonth = 1 + ) + + assertEquals(2025, state.selectedYear) + } + + // ==================== Month Boundary Tests ==================== + + @Test + fun yearMonthPickerState_allMonths_areValid() { + for (month in 1..12) { + val state = YearMonthPickerState( + initialYear = 2024, + initialMonth = month + ) + assertEquals(month, state.selectedMonth, "Month $month should be correctly stored") + } + } + + // ==================== State Independence Tests ==================== + + @Test + fun yearMonthPickerState_multipleInstances_areIndependent() { + val state1 = YearMonthPickerState( + initialYear = 2020, + initialMonth = 3 + ) + + val state2 = YearMonthPickerState( + initialYear = 2024, + initialMonth = 9 + ) + + assertEquals(2020, state1.selectedYear) + assertEquals(2024, state2.selectedYear) + assertEquals(3, state1.selectedMonth) + assertEquals(9, state2.selectedMonth) + } + + @Test + fun yearMonthPickerState_sameValues_areEqual() { + val state1 = YearMonthPickerState( + initialYear = 2024, + initialMonth = 6 + ) + + val state2 = YearMonthPickerState( + initialYear = 2024, + initialMonth = 6 + ) + + assertEquals(state1.selectedYear, state2.selectedYear) + assertEquals(state1.selectedMonth, state2.selectedMonth) + } + + @Test + fun yearMonthPickerState_differentValues_areNotEqual() { + val state1 = YearMonthPickerState( + initialYear = 2024, + initialMonth = 6 + ) + + val state2 = YearMonthPickerState( + initialYear = 2025, + initialMonth = 7 + ) + + assertNotEquals(state1.selectedYear, state2.selectedYear) + assertNotEquals(state1.selectedMonth, state2.selectedMonth) + } + + // ==================== Special Date Tests ==================== + + @Test + fun yearMonthPickerState_leapYear_isCorrect() { + val state = YearMonthPickerState( + initialYear = 2024, + initialMonth = 2 + ) + + assertEquals(2024, state.selectedYear) + assertEquals(2, state.selectedMonth) + } + + @Test + fun yearMonthPickerState_centuryYear_isCorrect() { + val state = YearMonthPickerState( + initialYear = 2000, + initialMonth = 2 + ) + + assertEquals(2000, state.selectedYear) + assertEquals(2, state.selectedMonth) + } + + @Test + fun yearMonthPickerState_endOfYear_isCorrect() { + val state = YearMonthPickerState( + initialYear = 2024, + initialMonth = 12 + ) + + assertEquals(2024, state.selectedYear) + assertEquals(12, state.selectedMonth) + } + + @Test + fun yearMonthPickerState_startOfYear_isCorrect() { + val state = YearMonthPickerState( + initialYear = 2024, + initialMonth = 1 + ) + + assertEquals(2024, state.selectedYear) + assertEquals(1, state.selectedMonth) + } +} diff --git a/datetimepicker/src/commonTest/kotlin/com/kez/picker/date/DatePickerStateTest.kt b/datetimepicker/src/commonTest/kotlin/com/kez/picker/date/DatePickerStateTest.kt new file mode 100644 index 0000000..449456c --- /dev/null +++ b/datetimepicker/src/commonTest/kotlin/com/kez/picker/date/DatePickerStateTest.kt @@ -0,0 +1,117 @@ +package com.kez.picker.date + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class DatePickerStateTest { + + @Test + fun testMaxDay_January() { + // 2023-01-01 + val state = DatePickerState(initialYear = 2023, initialMonth = 1, initialDay = 1) + assertEquals(31, state.maxDay) + } + + @Test + fun testMaxDay_February_NonLeapYear() { + // 2023-02-01 + val state = DatePickerState(initialYear = 2023, initialMonth = 2, initialDay = 1) + assertEquals(28, state.maxDay) + } + + @Test + fun testMaxDay_February_LeapYear() { + // 2024-02-01 (Leap Year) + val state = DatePickerState(initialYear = 2024, initialMonth = 2, initialDay = 1) + assertEquals(29, state.maxDay) + } + + @Test + fun testMaxDay_February_LeapYear_Century() { + // 2000-02-01 (Leap Year) + val state = DatePickerState(initialYear = 2000, initialMonth = 2, initialDay = 1) + assertEquals(29, state.maxDay) + } + + @Test + fun testMaxDay_February_NonLeapYear_Century() { + // 1900-02-01 (Not a Leap Year) + val state = DatePickerState(initialYear = 1900, initialMonth = 2, initialDay = 1) + assertEquals(28, state.maxDay) + } + + @Test + fun testMaxDay_April() { + // 2023-04-01 + val state = DatePickerState(initialYear = 2023, initialMonth = 4, initialDay = 1) + assertEquals(30, state.maxDay) + } + + @Test + fun testInitialDay_Clamped_WhenExceedsMaxDay() { + // Trying to set Feb 30 should be clamped to Feb 28 + val state = DatePickerState(initialYear = 2023, initialMonth = 2, initialDay = 30) + assertEquals(28, state.selectedDay) + } + + @Test + fun testInitialDay_Clamped_LeapYear() { + // Trying to set Feb 30 on leap year should be clamped to Feb 29 + val state = DatePickerState(initialYear = 2024, initialMonth = 2, initialDay = 30) + assertEquals(29, state.selectedDay) + } + + @Test + fun testInitialMonth_Throws_WhenOutOfRange() { + assertFailsWith { + DatePickerState(initialYear = 2024, initialMonth = 13, initialDay = 1) + } + } + + @Test + fun testInitialDay_Throws_WhenLessThanOne() { + assertFailsWith { + DatePickerState(initialYear = 2024, initialMonth = 1, initialDay = 0) + } + } + + @Test + fun testSelectedValues_MatchInitialValues() { + val state = DatePickerState(initialYear = 2025, initialMonth = 6, initialDay = 15) + assertEquals(2025, state.selectedYear) + assertEquals(6, state.selectedMonth) + assertEquals(15, state.selectedDay) + } + + @Test + fun testMaxDay_AllMonths() { + val daysInMonth = mapOf( + 1 to 31, 2 to 28, 3 to 31, 4 to 30, + 5 to 31, 6 to 30, 7 to 31, 8 to 31, + 9 to 30, 10 to 31, 11 to 30, 12 to 31 + ) + + daysInMonth.forEach { (month, expectedDays) -> + val state = DatePickerState(initialYear = 2023, initialMonth = month, initialDay = 1) + assertEquals(expectedDays, state.maxDay, "Month $month should have $expectedDays days") + } + } + + @Test + fun testMaxDay_February_LeapYearVariations() { + // Test various leap years + val leapYears = listOf(2000, 2004, 2020, 2024, 2400) + val nonLeapYears = listOf(1900, 2100, 2023, 2025) + + leapYears.forEach { year -> + val state = DatePickerState(initialYear = year, initialMonth = 2, initialDay = 1) + assertEquals(29, state.maxDay, "Year $year should be a leap year with 29 days in Feb") + } + + nonLeapYears.forEach { year -> + val state = DatePickerState(initialYear = year, initialMonth = 2, initialDay = 1) + assertEquals(28, state.maxDay, "Year $year should not be a leap year with 28 days in Feb") + } + } +} diff --git a/datetimepicker/src/commonTest/kotlin/com/kez/picker/util/TimeCalculationTest.kt b/datetimepicker/src/commonTest/kotlin/com/kez/picker/util/TimeCalculationTest.kt index 5789347..323dc33 100644 --- a/datetimepicker/src/commonTest/kotlin/com/kez/picker/util/TimeCalculationTest.kt +++ b/datetimepicker/src/commonTest/kotlin/com/kez/picker/util/TimeCalculationTest.kt @@ -16,7 +16,7 @@ class TimeCalculationTest { private val testDate: LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date private val currentYear: Int = testDate.year private val currentMonth: Int = testDate.month.number - private val currentDate: LocalDate = testDate + private val currentDay: Int = testDate.day // 24시간 형식에서 23:45 입력 시 동일한 시간이 반환되는지 확인 @Test @@ -30,7 +30,7 @@ class TimeCalculationTest { val expected = LocalDateTime( year = currentYear, month = currentMonth, - day = currentDate.day, + day = currentDay, hour = 23, minute = 45 ) @@ -51,7 +51,7 @@ class TimeCalculationTest { val expected = LocalDateTime( year = currentYear, month = currentMonth, - day = currentDate.day, + day = currentDay, hour = 15, minute = 15 ) @@ -72,7 +72,7 @@ class TimeCalculationTest { val expected = LocalDateTime( year = currentYear, month = currentMonth, - day = currentDate.day, + day = currentDay, hour = 0, minute = 0 ) @@ -93,7 +93,7 @@ class TimeCalculationTest { val expected = LocalDateTime( year = currentYear, month = currentMonth, - day = currentDate.day, + day = currentDay, hour = 12, minute = 30 ) diff --git a/gradle.properties b/gradle.properties index f369053..e7b955c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -32,7 +32,7 @@ kotlin.daemon.jvmargs=-Xmx4g -Xms1g -XX:ReservedCodeCacheSize=512m # Project Information GROUP=io.github.kez-lab POM_ARTIFACT_ID=compose-date-time-picker -VERSION_NAME=0.4.0 +VERSION_NAME=0.5.0 POM_NAME=Compose-DateTimePicker POM_DESCRIPTION=Compose Multiplatform DateTimePicker library supporting Android, iOS, Desktop and Web diff --git a/sample/src/commonMain/kotlin/com/kez/picker/sample/App.kt b/sample/src/commonMain/kotlin/com/kez/picker/sample/App.kt index f59110a..b094e0a 100644 --- a/sample/src/commonMain/kotlin/com/kez/picker/sample/App.kt +++ b/sample/src/commonMain/kotlin/com/kez/picker/sample/App.kt @@ -14,6 +14,7 @@ import com.kez.picker.sample.ui.screen.DatePickerSampleScreen import com.kez.picker.sample.ui.screen.HomeScreen import com.kez.picker.sample.ui.screen.IntegratedPickerScreen import com.kez.picker.sample.ui.screen.TimePickerSampleScreen +import com.kez.picker.sample.ui.screen.YearMonthPickerSampleScreen import com.kez.picker.sample.ui.theme.AppTheme @Composable @@ -46,6 +47,11 @@ fun App() { onBackPressed = { handleNavigateBack(navController) } ) } + composable(Screen.YearMonthPicker.route) { + YearMonthPickerSampleScreen( + onBackPressed = { handleNavigateBack(navController) } + ) + } composable(Screen.DatePicker.route) { DatePickerSampleScreen( onBackPressed = { handleNavigateBack(navController) } @@ -63,4 +69,4 @@ fun App() { } } } -} \ No newline at end of file +} diff --git a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/navigation/Screen.kt b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/navigation/Screen.kt index ab4fdd9..ad924e6 100644 --- a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/navigation/Screen.kt +++ b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/navigation/Screen.kt @@ -4,6 +4,7 @@ sealed class Screen(val route: String) { object Home : Screen("home") object Integrated : Screen("integrated") object TimePicker : Screen("time_picker") + object YearMonthPicker : Screen("year_month_picker") object DatePicker : Screen("date_picker") object BottomSheet : Screen("bottom_sheet") object BackgroundStyle : Screen("background_style") diff --git a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/BackgroundStylePickerScreen.kt b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/BackgroundStylePickerScreen.kt index d241513..27b1f84 100644 --- a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/BackgroundStylePickerScreen.kt +++ b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/BackgroundStylePickerScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.kez.picker.PickerDefaults import com.kez.picker.date.YearMonthPicker import com.kez.picker.rememberTimePickerState import com.kez.picker.rememberYearMonthPickerState @@ -51,12 +52,13 @@ internal fun BackgroundStylePickerScreen( onBackPressed: () -> Unit = {}, ) { val yearMonthState = rememberYearMonthPickerState( - initialYear = currentDate.year, - initialMonth = currentDate.monthNumber + initialYear = currentDate().year, + initialMonth = currentDate().month.number ) + val currentHour = currentHour() val timeState = rememberTimePickerState( initialHour = currentHour, - initialMinute = currentMinute, + initialMinute = currentMinute(), initialPeriod = if (currentHour >= 12) TimePeriod.PM else TimePeriod.AM, timeFormat = TimeFormat.HOUR_12 ) @@ -86,7 +88,7 @@ internal fun BackgroundStylePickerScreen( ) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent) + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) ) }, containerColor = MaterialTheme.colorScheme.background @@ -124,17 +126,21 @@ internal fun BackgroundStylePickerScreen( ) { YearMonthPicker( state = yearMonthState, - textStyle = TextStyle( - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ), - selectedTextStyle = TextStyle( - fontSize = 22.sp, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold + textStyles = PickerDefaults.textStyles( + textStyle = TextStyle( + fontSize = 18.sp + ), + selectedTextStyle = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) ), isDividerVisible = false, - selectedItemBackgroundColor = MaterialTheme.colorScheme.primaryContainer + colors = PickerDefaults.colors( + textColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + selectedTextColor = MaterialTheme.colorScheme.primary, + selectedItemBackgroundColor = MaterialTheme.colorScheme.primaryContainer + ) ) } } @@ -157,17 +163,21 @@ internal fun BackgroundStylePickerScreen( ) { TimePicker( state = timeState, - textStyle = TextStyle( - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ), - selectedTextStyle = TextStyle( - fontSize = 22.sp, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold + textStyles = PickerDefaults.textStyles( + textStyle = TextStyle( + fontSize = 18.sp + ), + selectedTextStyle = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) ), isDividerVisible = false, - selectedItemBackgroundColor = MaterialTheme.colorScheme.primaryContainer + colors = PickerDefaults.colors( + textColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + selectedTextColor = MaterialTheme.colorScheme.primary, + selectedItemBackgroundColor = MaterialTheme.colorScheme.primaryContainer + ) ) } } diff --git a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/BottomSheetSampleScreen.kt b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/BottomSheetSampleScreen.kt index 037b2fd..8fd7c5c 100644 --- a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/BottomSheetSampleScreen.kt +++ b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/BottomSheetSampleScreen.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.navigation.NavController +import com.kez.picker.PickerDefaults import com.kez.picker.date.YearMonthPicker import com.kez.picker.rememberTimePickerState import com.kez.picker.rememberYearMonthPickerState @@ -60,11 +60,13 @@ import kotlinx.datetime.number internal fun BottomSheetSampleScreen( onBackPressed: () -> Unit = {}, ) { - // Date/time state management + val currentDate = currentDate() val yearMonthState = rememberYearMonthPickerState( initialYear = currentDate.year, - initialMonth = currentDate.monthNumber + initialMonth = currentDate.month.number ) + val currentHour = currentHour() + val currentMinute = currentMinute() val timeState = rememberTimePickerState( initialHour = currentHour, initialMinute = currentMinute, @@ -246,16 +248,20 @@ internal fun BottomSheetSampleScreen( ) { YearMonthPicker( state = yearMonthState, - textStyle = TextStyle( - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + textStyles = PickerDefaults.textStyles( + textStyle = TextStyle( + fontSize = 18.sp + ), + selectedTextStyle = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) ), - selectedTextStyle = TextStyle( - fontSize = 22.sp, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ), - dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + colors = PickerDefaults.colors( + textColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + selectedTextColor = MaterialTheme.colorScheme.primary, + dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) ) } @@ -310,16 +316,20 @@ internal fun BottomSheetSampleScreen( ) { TimePicker( state = timeState, - textStyle = TextStyle( - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + textStyles = PickerDefaults.textStyles( + textStyle = TextStyle( + fontSize = 18.sp + ), + selectedTextStyle = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) ), - selectedTextStyle = TextStyle( - fontSize = 22.sp, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ), - dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + colors = PickerDefaults.colors( + textColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + selectedTextColor = MaterialTheme.colorScheme.primary, + dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) ) } diff --git a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/DatePickerSampleScreen.kt b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/DatePickerSampleScreen.kt index 5d79a23..a3c3243 100644 --- a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/DatePickerSampleScreen.kt +++ b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/DatePickerSampleScreen.kt @@ -1,6 +1,7 @@ package com.kez.picker.sample.ui.screen -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -8,116 +9,120 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -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.draw.clip +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.kez.picker.date.YearMonthPicker -import com.kez.picker.rememberYearMonthPickerState -import com.kez.picker.sample.getMonthName -import com.kez.picker.util.currentDate +import com.kez.picker.PickerDefaults +import com.kez.picker.date.DatePicker +import com.kez.picker.date.rememberDatePickerState import compose.icons.FeatherIcons import compose.icons.feathericons.ArrowLeft import compose.icons.feathericons.Calendar -import kotlinx.datetime.number @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun DatePickerSampleScreen( - onBackPressed: () -> Unit = {}, +fun DatePickerSampleScreen( + onBackPressed: () -> Unit ) { - val state = rememberYearMonthPickerState( - initialYear = currentDate.year, - initialMonth = currentDate.monthNumber - ) - - // Calculate selected date text - val selectedDateText by remember { - derivedStateOf { - "${state.selectedYear}년 ${getMonthName(state.selectedMonth)}" - } - } - Scaffold( topBar = { - CenterAlignedTopAppBar( - title = { Text("DatePicker Sample", fontWeight = FontWeight.Bold) }, + TopAppBar( + title = { Text("DatePicker Sample") }, navigationIcon = { - IconButton(onClick = { - onBackPressed() - }) { - Icon(FeatherIcons.ArrowLeft, contentDescription = "Back") + IconButton(onClick = onBackPressed) { + Icon( + imageVector = FeatherIcons.ArrowLeft, + contentDescription = "Back" + ) } - } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) ) } - ) { + ) { paddingValues -> Column( - modifier = Modifier.fillMaxSize().padding(it).padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Selected result display card - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - elevation = CardDefaults.cardElevation(2.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + val state = rememberDatePickerState() + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .padding(24.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = FeatherIcons.Calendar, - contentDescription = "Selected date", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.padding(horizontal = 8.dp)) - Text( - text = "Selected date: $selectedDateText", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + DatePicker( + state = state, + visibleItemsCount = 3, + textStyles = PickerDefaults.textStyles( + textStyle = MaterialTheme.typography.bodyLarge, + selectedTextStyle = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + ), + colors = PickerDefaults.colors( + selectedTextColor = MaterialTheme.colorScheme.primary, + dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) ) - } + ) } Spacer(modifier = Modifier.height(32.dp)) - YearMonthPicker( - modifier = Modifier.padding(horizontal = 12.dp), - state = state - ) - - // TODO: Add day selection capability - Spacer(modifier = Modifier.height(16.dp)) + // Display Selected Value + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(16.dp) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Selected Date", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = FeatherIcons.Calendar, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${state.selectedYear}-${ + state.selectedMonth.toString().padStart(2, '0') + }-${state.selectedDay.toString().padStart(2, '0')}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } } } } - -@Preview -@Composable -fun DatePickerSampleScreenPreview() { - DatePickerSampleScreen() -} \ No newline at end of file diff --git a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/HomeScreen.kt b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/HomeScreen.kt index 48ad530..a571adb 100644 --- a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/HomeScreen.kt +++ b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/HomeScreen.kt @@ -50,7 +50,7 @@ internal fun HomeScreen(navController: NavController) { fontWeight = FontWeight.Bold ) }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent ) ) @@ -79,9 +79,17 @@ internal fun HomeScreen(navController: NavController) { } item { MenuListItem( - title = "DatePicker Sample", + title = "YearMonthPicker Sample", description = "Standalone YearMonthPicker component", icon = FeatherIcons.Calendar, + onClick = { navController.navigate(Screen.YearMonthPicker.route) } + ) + } + item { + MenuListItem( + title = "DatePicker Sample", + description = "Full DatePicker (Year, Month, Day)", + icon = FeatherIcons.Calendar, onClick = { navController.navigate(Screen.DatePicker.route) } ) } diff --git a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/IntegratedPickerScreen.kt b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/IntegratedPickerScreen.kt index dfda8f7..954d17d 100644 --- a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/IntegratedPickerScreen.kt +++ b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/IntegratedPickerScreen.kt @@ -30,9 +30,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -49,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.kez.picker.PickerDefaults import com.kez.picker.date.YearMonthPicker import com.kez.picker.rememberTimePickerState import com.kez.picker.rememberYearMonthPickerState @@ -71,6 +72,10 @@ import kotlinx.datetime.number internal fun IntegratedPickerScreen( onBackPressed: () -> Unit = {}, ) { + val currentDate = currentDate() + val currentHour = currentHour() + val currentMinute = currentMinute() + val yearMonthState = rememberYearMonthPickerState( initialYear = currentDate.year, initialMonth = currentDate.month.number @@ -109,7 +114,7 @@ internal fun IntegratedPickerScreen( ) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent) + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) ) }, containerColor = MaterialTheme.colorScheme.background @@ -130,7 +135,7 @@ internal fun IntegratedPickerScreen( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - TabRow( + PrimaryTabRow( selectedTabIndex = selectedTabIndex, containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.primary, @@ -168,30 +173,38 @@ internal fun IntegratedPickerScreen( if (tabIndex == 0) { YearMonthPicker( state = yearMonthState, - textStyle = TextStyle( - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ), - selectedTextStyle = TextStyle( - fontSize = 22.sp, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold + textStyles = PickerDefaults.textStyles( + textStyle = TextStyle( + fontSize = 18.sp + ), + selectedTextStyle = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) ), - dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + colors = PickerDefaults.colors( + textColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + selectedTextColor = MaterialTheme.colorScheme.primary, + dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) ) } else { TimePicker( state = timeState, - textStyle = TextStyle( - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + textStyles = PickerDefaults.textStyles( + textStyle = TextStyle( + fontSize = 18.sp + ), + selectedTextStyle = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) ), - selectedTextStyle = TextStyle( - fontSize = 22.sp, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ), - dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + colors = PickerDefaults.colors( + textColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + selectedTextColor = MaterialTheme.colorScheme.primary, + dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) ) } } @@ -278,4 +291,4 @@ internal fun SelectedDateTimeCard(date: String, time: String) { @Composable fun PreviewTimeCard() { IntegratedPickerScreen() -} \ No newline at end of file +} diff --git a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/TimePickerSampleScreen.kt b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/TimePickerSampleScreen.kt index f0df747..852f40a 100644 --- a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/TimePickerSampleScreen.kt +++ b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/TimePickerSampleScreen.kt @@ -17,9 +17,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -54,6 +54,9 @@ internal fun TimePickerSampleScreen( onBackPressed: () -> Unit = {}, ) { var selectedFormat by remember { mutableIntStateOf(0) } + val currentHour = currentHour() + val currentMinute = currentMinute() + val timeState12 = rememberTimePickerState( initialHour = currentHour, initialMinute = currentMinute, @@ -148,7 +151,7 @@ internal fun TimePickerSampleScreen( Spacer(modifier = Modifier.height(24.dp)) - TabRow( + PrimaryTabRow( selectedTabIndex = selectedFormat, modifier = Modifier.clip(RoundedCornerShape(16.dp)) ) { @@ -180,4 +183,4 @@ internal fun TimePickerSampleScreen( @Composable fun TimePickerSampleScreenPreview() { TimePickerSampleScreen() -} \ No newline at end of file +} diff --git a/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/YearMonthPickerSampleScreen.kt b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/YearMonthPickerSampleScreen.kt new file mode 100644 index 0000000..71fb41d --- /dev/null +++ b/sample/src/commonMain/kotlin/com/kez/picker/sample/ui/screen/YearMonthPickerSampleScreen.kt @@ -0,0 +1,121 @@ +package com.kez.picker.sample.ui.screen + +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.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.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kez.picker.date.YearMonthPicker +import com.kez.picker.rememberYearMonthPickerState +import com.kez.picker.sample.getMonthName +import com.kez.picker.util.currentDate +import compose.icons.FeatherIcons +import compose.icons.feathericons.ArrowLeft +import compose.icons.feathericons.Calendar +import kotlinx.datetime.number + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun YearMonthPickerSampleScreen( + onBackPressed: () -> Unit = {}, +) { + val currentDate = currentDate() + val state = rememberYearMonthPickerState( + initialYear = currentDate.year, + initialMonth = currentDate.month.number + ) + + // Calculate selected date text + val selectedDateText by remember { + derivedStateOf { + "${state.selectedYear}년 ${getMonthName(state.selectedMonth)}" + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("YearMonthPicker Sample", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = { + onBackPressed() + }) { + Icon(FeatherIcons.ArrowLeft, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) + ) + } + ) { + Column( + modifier = Modifier.fillMaxSize().padding(it).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Selected result display card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(2.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = FeatherIcons.Calendar, + contentDescription = "Selected date", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.padding(horizontal = 8.dp)) + Text( + text = "Selected date: $selectedDateText", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + YearMonthPicker( + modifier = Modifier.padding(horizontal = 12.dp), + state = state + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } +}