From b126d9bf31875dd57146c0decc8080d6b63a4ea1 Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 00:02:09 +0800 Subject: [PATCH 01/11] [feat](font-icon) Add `tint` parameter --- .../github/composefluent/component/FontIcon.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/FontIcon.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/FontIcon.kt index a4d98f06..52d9578b 100644 --- a/fluent/src/commonMain/kotlin/io/github/composefluent/component/FontIcon.kt +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/FontIcon.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.layout @@ -67,13 +68,14 @@ internal fun FontIcon( modifier: Modifier = Modifier, iconSize: TextUnit = FontIconSize.Standard.value.sp, fallback: (@Composable () -> Unit)? = null, + tint: Color = LocalContentColor.current.copy(LocalContentAlpha.current) ) { if (LocalFontIconFontFamily.current != null || fallback == null) { Text( text = glyph.toString(), fontFamily = LocalFontIconFontFamily.current, fontSize = iconSize, - color = LocalContentColor.current.copy(LocalContentAlpha.current), + color = tint, modifier = Modifier.then(modifier) .height(with(LocalDensity.current) { iconSize.toDp() }), onTextLayout = { @@ -91,7 +93,8 @@ internal fun FontIcon( contentDescription: String?, modifier: Modifier = Modifier, iconSize: FontIconSize = FontIconSize.Standard, - fallbackSize: FontIconSize = iconSize + fallbackSize: FontIconSize = iconSize, + tint: Color = LocalContentColor.current.copy(LocalContentAlpha.current) ) { FontIcon( glyph = glyph, @@ -104,6 +107,7 @@ internal fun FontIcon( Icon( imageVector = vector(), contentDescription = contentDescription, + tint = tint, modifier = modifier .layout { measurable, constraints -> val size = fallbackSize.value.dp.roundToPx() @@ -120,7 +124,8 @@ internal fun FontIcon( } ) } - } + }, + tint = tint ) } @@ -196,7 +201,8 @@ fun FontIcon( contentDescription: String?, modifier: Modifier = Modifier, size: FontIconSize = FontIconSize.Standard, - fallbackSize: FontIconSize = FontIconSize(size.value + 2f) + fallbackSize: FontIconSize = FontIconSize(size.value + 2f), + tint: Color = LocalContentColor.current.copy(LocalContentAlpha.current) ) { FontIcon( glyph = type.glyph, @@ -204,7 +210,8 @@ fun FontIcon( contentDescription = contentDescription, iconSize = size, fallbackSize = fallbackSize, - modifier = modifier + modifier = modifier, + tint = tint ) } From 98ac86776d39f76433e6ee3c946f1a36231327e0 Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 00:03:32 +0800 Subject: [PATCH 02/11] [feat](time-picker) Add a basic timer picker component --- .../composefluent/component/TimePicker.kt | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt new file mode 100644 index 00000000..7f16cb80 --- /dev/null +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt @@ -0,0 +1,441 @@ +package io.github.composefluent.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.github.composefluent.FluentTheme +import io.github.composefluent.LocalAcrylicPopupEnabled +import io.github.composefluent.LocalContentAlpha +import io.github.composefluent.LocalContentColor +import io.github.composefluent.animation.FluentDuration +import io.github.composefluent.animation.FluentEasing +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalTime + +@Composable +fun TimePicker( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier = Modifier, + disabled: Boolean = false +) { + val is24Hour: Boolean = true // TODO[feat]: Support 12-hours + var open by remember { mutableStateOf(false) } + + BasicFlyoutContainer( + flyout = { + BasicFlyout( + visible = open, + onDismissRequest = { open = false }, + contentPadding = PaddingValues(0.dp) + ) { + var candidateHour by remember { mutableStateOf(0) } + var candidateMinutes by remember { mutableStateOf(0) } + var candidateSeconds by remember { mutableStateOf(0) } + + Column(Modifier.width(300.dp) + // TODO[optimize](time-picker): Should we use acrylic effect? + // If we use acrylic effect, it would be a trouble to hide the text below caret buttons + .background(FluentTheme.colors.background.acrylic.default) + ) { + Box(Modifier) { + // Base indicator + Box( + Modifier.height(40.dp).fillMaxWidth().padding(horizontal = 6.dp).background( + color = FluentTheme.colors.fillAccent.default, + shape = FluentTheme.shapes.control + ).align(Alignment.Center) + ) { + Row { + Spacer(Modifier.weight(1f)) + Box( + Modifier.width(1.dp).height(40.dp) + .background(FluentTheme.colors.stroke.control.onAccentTertiary) + ) + Spacer(Modifier.weight(1f)) + } + } + // Wheels + Row(Modifier.height(360.dp)) { + // Hour + Box(Modifier.weight(1f)) { + InfiniteWheelPicker( + hours24, + initialValue = value?.hour?.toString(), + onSelectedValueChange = { + candidateHour = it.toInt() + }) + } + Box( + Modifier.width(1.dp).fillMaxHeight() + .background(FluentTheme.colors.stroke.divider.default) + ) + // Minute + Box(Modifier.weight(1f)) { + InfiniteWheelPicker( + minutes, + initialValue = value?.minute?.toString(), + onSelectedValueChange = { + candidateMinutes = it.toInt() + }) + } + } + } + Box(Modifier.height(1.dp).fillMaxWidth().background(FluentTheme.colors.stroke.divider.default)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + SubtleButton( + modifier = Modifier.padding(4.dp).height(36.dp).weight(1f), + onClick = { + onValueChange(LocalTime(candidateHour, candidateMinutes, candidateSeconds)) + open = false + }) { + FontIcon( + type = FontIconPrimitive.Accept, + contentDescription = "Accept" + ) + } + SubtleButton( + modifier = Modifier.padding(4.dp).height(38.dp).weight(1f), + onClick = { + open = false + }) { + FontIcon( + type = FontIconPrimitive.Cancel, + contentDescription = "Cancel" + ) + } + } + } + } + } + ) { + TimePickerButton( + modifier = modifier, + value = value, + is24Hour = is24Hour, + disabled = disabled, + onClick = { open = true } + ) + } +} + +@Composable +private fun TimePickerButton(modifier: Modifier, value: LocalTime?, is24Hour: Boolean, disabled: Boolean, onClick: () -> Unit) { + Button( + modifier = modifier.width(300.dp), + onClick = onClick, + disabled = disabled + ) { + val labelColor = when { + disabled -> FluentTheme.colors.text.text.tertiary + value == null -> FluentTheme.colors.text.text.secondary + else -> FluentTheme.colors.text.text.primary + } + val hour = value?.let { + when { + is24Hour -> value.hour + value.hour == 0 -> 12 // 0 -> 12AM + value.hour <= 12 -> value.hour + else -> value.hour - 12 + } + } + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + text = hour?.toString() ?: "hour", + color = labelColor + ) + Box(Modifier.size(1.dp, 30.dp).background(FluentTheme.colors.stroke.control.default)) + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + text = value?.minute?.let { formatMinute(it) } ?: "minute", + color = labelColor + ) + if (!is24Hour) { + val isAm = if (value != null) { + value.hour < 12 + } else { + true + } + + // TODO[i18n](TimePicker) + Box(Modifier.size(1.dp, 30.dp).background(FluentTheme.colors.stroke.control.default)) + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + text = if (isAm) "AM" else "PM", + color = labelColor + ) + } + } +} + +private val hours24 = (0..23).map { it.toString() } + +private val minutes = (0..59).map(::formatMinute) + +private fun formatMinute(value: Int): String = + if (value < 10) "0$value" + else value.toString() + + +@Composable +private fun InfiniteWheelPicker( + items: List, + visibleItemsCount: Int = 9, + initialValue: String?, + onSelectedValueChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + require(visibleItemsCount % 2 == 1) { "visibleItemsCount must be odd" } + + val itemHeight = 40.dp + // Creates a virtual list (big enough to simulate infinite scroll) + val virtualListSize = items.size * 100 + val initialValueIndex = remember(items, initialValue) { + if (initialValue != null) items.indexOf(initialValue) + else 0 + } + val centerOffset = (visibleItemsCount - 1) / 2 + + // Set the initial position to the center of the list + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = virtualListSize / 2 - centerOffset + initialValueIndex + ) + + // 当前选中的值 + val selectedValue by remember { + derivedStateOf { + val centerIndex = listState.firstVisibleItemIndex + centerOffset + items[centerIndex % items.size] + } + } + + LaunchedEffect(selectedValue) { + onSelectedValueChange(selectedValue) + } + + val snapBehavior = rememberSnapFlingBehavior( + lazyListState = listState, + ) + + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + + Box( + modifier + .hoverable(interactionSource) + ) { + val scrollScope = rememberCoroutineScope() + var currentTargetScrollIndex by remember { mutableStateOf(0) } + + fun next() { + scrollScope.launch { + val target = if (listState.isScrollInProgress) { + currentTargetScrollIndex + 1 + } else { + listState.firstVisibleItemIndex + 1 + } + currentTargetScrollIndex = target + listState.animateScrollToItem(target) + } + } + + fun previous() { + scrollScope.launch { + val target = if (listState.isScrollInProgress) { + currentTargetScrollIndex - 1 + } else { + listState.firstVisibleItemIndex - 1 + } + currentTargetScrollIndex = target + listState.animateScrollToItem(target) + } + } + + fun nextPage() { + scrollScope.launch { + val target = if (listState.isScrollInProgress) { + currentTargetScrollIndex + visibleItemsCount + } else { + listState.firstVisibleItemIndex + visibleItemsCount + } + currentTargetScrollIndex = target + listState.animateScrollToItem(target) + } + } + + fun previousPage() { + scrollScope.launch { + val target = if (listState.isScrollInProgress) { + currentTargetScrollIndex - visibleItemsCount + } else { + listState.firstVisibleItemIndex - visibleItemsCount + } + currentTargetScrollIndex = target + listState.animateScrollToItem(target) + } + } + + val focusRequester = remember { FocusRequester() } + + LazyColumn( + state = listState, + flingBehavior = snapBehavior, // Only use fling behavior for touchpad gesture. + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(vertical = 0.dp), + modifier = Modifier + .fillMaxWidth() + .height(itemHeight * visibleItemsCount) + .pointerInput(Unit) { + awaitEachGesture { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Scroll) { + val change = event.changes.first() + // TODO[optimize](time-picker): Detect gesture scroll + if (change.scrollDelta.y > 0F) { + next() + } else if (change.scrollDelta.y < 0F) { + previous() + } + } else if (event.type == PointerEventType.Enter) { // Focus for receiving key input + focusRequester.requestFocus() + } + } + } + .focusable() + .focusRequester(focusRequester) + .onKeyEvent { + if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.DirectionUp -> { + previous() + } + + Key.DirectionDown -> { + next() + } + + Key.PageUp -> { + previousPage() + } + + Key.PageDown -> { + nextPage() + } + + else -> return@onKeyEvent false + } + return@onKeyEvent true + } + return@onKeyEvent false + }, + userScrollEnabled = false // TODO[optimize](time-picker): Detect gesture scroll + ) { + items(virtualListSize) { index -> + val actualIndex = index % items.size + val itemValue = items[actualIndex] + // Hide to leave space for caret buttons + val hide = false/*if (hovered) { + if (listState.isScrollInProgress) { + index <= listState.firstVisibleItemScrollOffset || index >= currentTargetScrollIndex + visibleItemsCount - 1 + } else { + index <= listState.firstVisibleItemIndex || index >= listState.firstVisibleItemIndex + visibleItemsCount - 1 + } + } else { + false + }*/ + + SubtleButton( + modifier = Modifier.height(40.dp).fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp) + .graphicsLayer { + alpha = if (hide) 0f else 1f + }, + onClick = { + // Select this item + scrollScope.launch { + listState.animateScrollToItem(index - centerOffset) + } + } + ) { + // TODO[optimize](time-picker): Use brush to implement color inversion? + val textColor = + if (selectedValue == itemValue) FluentTheme.colors.text.onAccent.primary + else FluentTheme.colors.text.text.primary + Text( + text = itemValue, + style = FluentTheme.typography.body, + color = textColor, + textAlign = TextAlign.Center + ) + } + } + } + if (hovered) { + CaretButton( + type = FontIconPrimitive.CaretUp, + contentDescription = "Up", + onClick = { previous() }, + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth() + ) + + CaretButton( + type = FontIconPrimitive.CaretDown, + contentDescription = "Down", + onClick = { next() }, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() + ) + } + } +} + +@Composable +private fun CaretButton( + type: FontIconPrimitive, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier.height(40.dp) + .background(FluentTheme.colors.background.acrylic.default) + .clickable(onClick = onClick, interactionSource = interactionSource, indication = null), + contentAlignment = Alignment.Center + ) { + val pressed by interactionSource.collectIsPressedAsState() + val hovered by interactionSource.collectIsHoveredAsState() + + val size by animateFloatAsState( + if (pressed) 7f else 8f, + tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ) + val color = if (hovered) FluentTheme.colors.text.text.secondary + else FluentTheme.colors.controlStrong.default + + FontIcon(type, contentDescription, size = FontIconSize(size), tint = color) + } +} \ No newline at end of file From 699b030bb41ffef4942f0369df5b8f7597fcf76c Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 17:33:39 +0800 Subject: [PATCH 03/11] [feat](time-picker) Support 12-hour clock mode --- .../composefluent/component/TimePicker.kt | 259 +++++++++--------- 1 file changed, 129 insertions(+), 130 deletions(-) diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt index 7f16cb80..52b2cff5 100644 --- a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt @@ -24,11 +24,10 @@ import androidx.compose.ui.input.key.* import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceAtLeast import io.github.composefluent.FluentTheme -import io.github.composefluent.LocalAcrylicPopupEnabled -import io.github.composefluent.LocalContentAlpha -import io.github.composefluent.LocalContentColor import io.github.composefluent.animation.FluentDuration import io.github.composefluent.animation.FluentEasing import kotlinx.coroutines.launch @@ -39,9 +38,9 @@ fun TimePicker( value: LocalTime?, onValueChange: (LocalTime?) -> Unit, modifier: Modifier = Modifier, + is12hour: Boolean = false, disabled: Boolean = false ) { - val is24Hour: Boolean = true // TODO[feat]: Support 12-hours var open by remember { mutableStateOf(false) } BasicFlyoutContainer( @@ -54,39 +53,28 @@ fun TimePicker( var candidateHour by remember { mutableStateOf(0) } var candidateMinutes by remember { mutableStateOf(0) } var candidateSeconds by remember { mutableStateOf(0) } + var candidateAmPm by remember { mutableStateOf("AM") } Column(Modifier.width(300.dp) // TODO[optimize](time-picker): Should we use acrylic effect? - // If we use acrylic effect, it would be a trouble to hide the text below caret buttons + // If we use acrylic effect, it would be difficult to hide the text below caret buttons .background(FluentTheme.colors.background.acrylic.default) ) { - Box(Modifier) { + Box { // Base indicator - Box( - Modifier.height(40.dp).fillMaxWidth().padding(horizontal = 6.dp).background( - color = FluentTheme.colors.fillAccent.default, - shape = FluentTheme.shapes.control - ).align(Alignment.Center) - ) { - Row { - Spacer(Modifier.weight(1f)) - Box( - Modifier.width(1.dp).height(40.dp) - .background(FluentTheme.colors.stroke.control.onAccentTertiary) - ) - Spacer(Modifier.weight(1f)) - } - } + BaseIndicator(is12hour) + // Wheels Row(Modifier.height(360.dp)) { // Hour Box(Modifier.weight(1f)) { InfiniteWheelPicker( - hours24, + items = if (is12hour) hours12 else hours24, initialValue = value?.hour?.toString(), - onSelectedValueChange = { - candidateHour = it.toInt() - }) + onSelectedValueChange = { candidateHour = it.toInt() }, + visibleItemsCount = 9, + ring = true + ) } Box( Modifier.width(1.dp).fillMaxHeight() @@ -95,11 +83,28 @@ fun TimePicker( // Minute Box(Modifier.weight(1f)) { InfiniteWheelPicker( - minutes, - initialValue = value?.minute?.toString(), - onSelectedValueChange = { - candidateMinutes = it.toInt() - }) + items = minutes, + initialValue = value?.minute?.let(::formatMinute), + onSelectedValueChange = { candidateMinutes = it.toInt() }, + visibleItemsCount = 9, + ring = true + ) + } + if (is12hour) { + Box( + Modifier.width(1.dp).fillMaxHeight() + .background(FluentTheme.colors.stroke.divider.default) + ) + // AM/PM + Box(Modifier.weight(1f)) { + InfiniteWheelPicker( + items = amPm, + initialValue = if ((value?.hour ?: 0) < 12) "AM" else "PM", + onSelectedValueChange = { candidateAmPm = it }, + visibleItemsCount = 9, + ring = false + ) + } } } } @@ -108,7 +113,11 @@ fun TimePicker( SubtleButton( modifier = Modifier.padding(4.dp).height(36.dp).weight(1f), onClick = { - onValueChange(LocalTime(candidateHour, candidateMinutes, candidateSeconds)) + if (is12hour) { + onValueChange(LocalTime(candidateHour, candidateMinutes, candidateSeconds)) + } else { + onValueChange(LocalTime(hour12to24(candidateHour, candidateAmPm == "AM"), candidateMinutes, candidateSeconds)) + } open = false }) { FontIcon( @@ -134,7 +143,7 @@ fun TimePicker( TimePickerButton( modifier = modifier, value = value, - is24Hour = is24Hour, + is12Hour = is12hour, disabled = disabled, onClick = { open = true } ) @@ -142,7 +151,34 @@ fun TimePicker( } @Composable -private fun TimePickerButton(modifier: Modifier, value: LocalTime?, is24Hour: Boolean, disabled: Boolean, onClick: () -> Unit) { +private fun BoxScope.BaseIndicator(is24Hour: Boolean) { + Box( + Modifier.height(40.dp).fillMaxWidth().align(Alignment.Center) + ) { + Box(Modifier.fillMaxSize().padding(horizontal = 6.dp).background( + color = FluentTheme.colors.fillAccent.default, + shape = FluentTheme.shapes.control + )) + Row { + Spacer(Modifier.weight(1f)) + Box( + Modifier.width(1.dp).height(40.dp) + .background(FluentTheme.colors.stroke.control.onAccentTertiary) + ) + Spacer(Modifier.weight(1f)) + if (!is24Hour) { + Box( + Modifier.width(1.dp).height(40.dp) + .background(FluentTheme.colors.stroke.control.onAccentTertiary) + ) + Spacer(Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun TimePickerButton(modifier: Modifier, value: LocalTime?, is12Hour: Boolean, disabled: Boolean, onClick: () -> Unit) { Button( modifier = modifier.width(300.dp), onClick = onClick, @@ -154,12 +190,8 @@ private fun TimePickerButton(modifier: Modifier, value: LocalTime?, is24Hour: Bo else -> FluentTheme.colors.text.text.primary } val hour = value?.let { - when { - is24Hour -> value.hour - value.hour == 0 -> 12 // 0 -> 12AM - value.hour <= 12 -> value.hour - else -> value.hour - 12 - } + if (is12Hour) hour24to12(value.hour) + else value.hour } Text( modifier = Modifier.weight(1f), @@ -174,12 +206,8 @@ private fun TimePickerButton(modifier: Modifier, value: LocalTime?, is24Hour: Bo text = value?.minute?.let { formatMinute(it) } ?: "minute", color = labelColor ) - if (!is24Hour) { - val isAm = if (value != null) { - value.hour < 12 - } else { - true - } + if (is12Hour) { + val isAm = value == null || value.hour < 12 // TODO[i18n](TimePicker) Box(Modifier.size(1.dp, 30.dp).background(FluentTheme.colors.stroke.control.default)) @@ -193,37 +221,33 @@ private fun TimePickerButton(modifier: Modifier, value: LocalTime?, is24Hour: Bo } } -private val hours24 = (0..23).map { it.toString() } - -private val minutes = (0..59).map(::formatMinute) - -private fun formatMinute(value: Int): String = - if (value < 10) "0$value" - else value.toString() - - @Composable private fun InfiniteWheelPicker( items: List, - visibleItemsCount: Int = 9, + visibleItemsCount: Int, initialValue: String?, onSelectedValueChange: (String) -> Unit, + ring: Boolean, + itemHeight: Dp = 40.dp, modifier: Modifier = Modifier ) { require(visibleItemsCount % 2 == 1) { "visibleItemsCount must be odd" } - val itemHeight = 40.dp // Creates a virtual list (big enough to simulate infinite scroll) - val virtualListSize = items.size * 100 + val virtualListSize = if (ring) items.size * 100 else items.size val initialValueIndex = remember(items, initialValue) { if (initialValue != null) items.indexOf(initialValue) else 0 } val centerOffset = (visibleItemsCount - 1) / 2 + val contentPadding = if (items.size < visibleItemsCount) itemHeight * (visibleItemsCount / 2) else 0.dp // Set the initial position to the center of the list val listState = rememberLazyListState( - initialFirstVisibleItemIndex = virtualListSize / 2 - centerOffset + initialValueIndex + initialFirstVisibleItemIndex = + if (ring) + (virtualListSize / 2 - centerOffset + initialValueIndex).coerceAtLeast(0) + else initialValueIndex ) // 当前选中的值 @@ -238,78 +262,40 @@ private fun InfiniteWheelPicker( onSelectedValueChange(selectedValue) } - val snapBehavior = rememberSnapFlingBehavior( - lazyListState = listState, - ) - val interactionSource = remember { MutableInteractionSource() } val hovered by interactionSource.collectIsHoveredAsState() - Box( - modifier - .hoverable(interactionSource) - ) { + Box(modifier.hoverable(interactionSource)) { val scrollScope = rememberCoroutineScope() var currentTargetScrollIndex by remember { mutableStateOf(0) } - fun next() { - scrollScope.launch { - val target = if (listState.isScrollInProgress) { - currentTargetScrollIndex + 1 - } else { - listState.firstVisibleItemIndex + 1 - } - currentTargetScrollIndex = target - listState.animateScrollToItem(target) - } - } - - fun previous() { + fun scroll(offset: Int) { scrollScope.launch { val target = if (listState.isScrollInProgress) { - currentTargetScrollIndex - 1 + currentTargetScrollIndex + offset } else { - listState.firstVisibleItemIndex - 1 + listState.firstVisibleItemIndex + offset } currentTargetScrollIndex = target - listState.animateScrollToItem(target) + listState.animateScrollToItem(target.fastCoerceAtLeast(0)) } } - fun nextPage() { - scrollScope.launch { - val target = if (listState.isScrollInProgress) { - currentTargetScrollIndex + visibleItemsCount - } else { - listState.firstVisibleItemIndex + visibleItemsCount - } - currentTargetScrollIndex = target - listState.animateScrollToItem(target) - } - } - - fun previousPage() { - scrollScope.launch { - val target = if (listState.isScrollInProgress) { - currentTargetScrollIndex - visibleItemsCount - } else { - listState.firstVisibleItemIndex - visibleItemsCount - } - currentTargetScrollIndex = target - listState.animateScrollToItem(target) - } - } + fun next() { scroll(1) } + fun previous() { scroll(-1) } + fun nextPage() { scroll(visibleItemsCount) } + fun previousPage() { scroll(-visibleItemsCount) } val focusRequester = remember { FocusRequester() } LazyColumn( state = listState, - flingBehavior = snapBehavior, // Only use fling behavior for touchpad gesture. + flingBehavior = rememberSnapFlingBehavior(listState), // Only use fling behavior for touchpad gesture. horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = PaddingValues(vertical = 0.dp), + contentPadding = PaddingValues(vertical = contentPadding), + verticalArrangement = Arrangement.Center, modifier = Modifier - .fillMaxWidth() - .height(itemHeight * visibleItemsCount) + .fillMaxSize() .pointerInput(Unit) { awaitEachGesture { val event = awaitPointerEvent() @@ -326,27 +312,14 @@ private fun InfiniteWheelPicker( } } } - .focusable() - .focusRequester(focusRequester) + .focusable().focusRequester(focusRequester) .onKeyEvent { if (it.type == KeyEventType.KeyDown) { when (it.key) { - Key.DirectionUp -> { - previous() - } - - Key.DirectionDown -> { - next() - } - - Key.PageUp -> { - previousPage() - } - - Key.PageDown -> { - nextPage() - } - + Key.DirectionUp -> previous() + Key.DirectionDown -> next() + Key.PageUp -> previousPage() + Key.PageDown -> nextPage() else -> return@onKeyEvent false } return@onKeyEvent true @@ -359,7 +332,8 @@ private fun InfiniteWheelPicker( val actualIndex = index % items.size val itemValue = items[actualIndex] // Hide to leave space for caret buttons - val hide = false/*if (hovered) { + val hide = false + /*val hide = if (hovered) { if (listState.isScrollInProgress) { index <= listState.firstVisibleItemScrollOffset || index >= currentTargetScrollIndex + visibleItemsCount - 1 } else { @@ -370,7 +344,7 @@ private fun InfiniteWheelPicker( }*/ SubtleButton( - modifier = Modifier.height(40.dp).fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp) + modifier = Modifier.height(itemHeight).fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp) .graphicsLayer { alpha = if (hide) 0f else 1f }, @@ -438,4 +412,29 @@ private fun CaretButton( FontIcon(type, contentDescription, size = FontIconSize(size), tint = color) } -} \ No newline at end of file +} + +private fun hour24to12(value: Int): Int = when { + value == 0 -> 12 // 0 -> 12AM + value <= 12 -> value + else -> value - 12 +} + +private fun hour12to24(value: Int, isAm: Boolean): Int { + val value = when { + value == 12 -> 0 + else -> value + } + return if (isAm) value else value + 12 +} + +private val hours24 = (0..23).map { it.toString() } +private val hours12 = (1..12).map { it.toString() } + +private val minutes = (0..59).map(::formatMinute) +private val amPm = listOf("AM", "PM") + +private fun formatMinute(value: Int): String = + if (value < 10) "0$value" + else value.toString() + From 8c8a79bdbc8f81a947eee1f579c7839e5c6caf06 Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 17:43:26 +0800 Subject: [PATCH 04/11] [fix](time-picker) Fix 12-hour divider --- .../kotlin/io/github/composefluent/component/TimePicker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt index 52b2cff5..01f6a9f5 100644 --- a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt @@ -166,7 +166,7 @@ private fun BoxScope.BaseIndicator(is24Hour: Boolean) { .background(FluentTheme.colors.stroke.control.onAccentTertiary) ) Spacer(Modifier.weight(1f)) - if (!is24Hour) { + if (is24Hour) { Box( Modifier.width(1.dp).height(40.dp) .background(FluentTheme.colors.stroke.control.onAccentTertiary) From 063985d24f5dfc59b4a8682bfa3f96e3427fd4a3 Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 17:45:19 +0800 Subject: [PATCH 05/11] [feat](time-picker) Enable user scroll in mobile platforms as a workaround --- .../component/TimePicker.android.kt | 23 ++++++++++++++++++ .../composefluent/component/TimePicker.kt | 24 +++++++++++++++---- .../component/TimePicker.desktop.kt | 23 ++++++++++++++++++ .../composefluent/component/TimePicker.ios.kt | 21 ++++++++++++++++ .../component/TimePicker.jsAndWasm.kt | 23 ++++++++++++++++++ 5 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 fluent/src/androidMain/kotlin/io/github/composefluent/component/TimePicker.android.kt create mode 100644 fluent/src/desktopMain/kotlin/io/github/composefluent/component/TimePicker.desktop.kt create mode 100644 fluent/src/iosMain/kotlin/io/github/composefluent/component/TimePicker.ios.kt create mode 100644 fluent/src/jsAndWasmMain/kotlin/io/github/composefluent/component/TimePicker.jsAndWasm.kt diff --git a/fluent/src/androidMain/kotlin/io/github/composefluent/component/TimePicker.android.kt b/fluent/src/androidMain/kotlin/io/github/composefluent/component/TimePicker.android.kt new file mode 100644 index 00000000..67608809 --- /dev/null +++ b/fluent/src/androidMain/kotlin/io/github/composefluent/component/TimePicker.android.kt @@ -0,0 +1,23 @@ +package io.github.composefluent.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.datetime.LocalTime + +@Composable +actual fun TimePicker( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier, + is12hour: Boolean, + disabled: Boolean +) { + TimePickerImpl( + value = value, + onValueChange = onValueChange, + modifier = modifier, + is12hour = is12hour, + disabled = disabled, + userScrollEnabled = true + ) +} \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt index 01f6a9f5..77584b27 100644 --- a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt @@ -34,12 +34,22 @@ import kotlinx.coroutines.launch import kotlinx.datetime.LocalTime @Composable -fun TimePicker( +expect fun TimePicker( value: LocalTime?, onValueChange: (LocalTime?) -> Unit, modifier: Modifier = Modifier, is12hour: Boolean = false, disabled: Boolean = false +) + +@Composable +internal fun TimePickerImpl( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier = Modifier, + is12hour: Boolean = false, + disabled: Boolean = false, + userScrollEnabled: Boolean ) { var open by remember { mutableStateOf(false) } @@ -73,7 +83,8 @@ fun TimePicker( initialValue = value?.hour?.toString(), onSelectedValueChange = { candidateHour = it.toInt() }, visibleItemsCount = 9, - ring = true + ring = true, + userScrollEnabled = userScrollEnabled ) } Box( @@ -87,7 +98,8 @@ fun TimePicker( initialValue = value?.minute?.let(::formatMinute), onSelectedValueChange = { candidateMinutes = it.toInt() }, visibleItemsCount = 9, - ring = true + ring = true, + userScrollEnabled = userScrollEnabled ) } if (is12hour) { @@ -102,7 +114,8 @@ fun TimePicker( initialValue = if ((value?.hour ?: 0) < 12) "AM" else "PM", onSelectedValueChange = { candidateAmPm = it }, visibleItemsCount = 9, - ring = false + ring = false, + userScrollEnabled = userScrollEnabled ) } } @@ -229,6 +242,7 @@ private fun InfiniteWheelPicker( onSelectedValueChange: (String) -> Unit, ring: Boolean, itemHeight: Dp = 40.dp, + userScrollEnabled: Boolean, modifier: Modifier = Modifier ) { require(visibleItemsCount % 2 == 1) { "visibleItemsCount must be odd" } @@ -326,7 +340,7 @@ private fun InfiniteWheelPicker( } return@onKeyEvent false }, - userScrollEnabled = false // TODO[optimize](time-picker): Detect gesture scroll + userScrollEnabled = userScrollEnabled // TODO[optimize](time-picker): Detect gesture scroll ) { items(virtualListSize) { index -> val actualIndex = index % items.size diff --git a/fluent/src/desktopMain/kotlin/io/github/composefluent/component/TimePicker.desktop.kt b/fluent/src/desktopMain/kotlin/io/github/composefluent/component/TimePicker.desktop.kt new file mode 100644 index 00000000..641380d8 --- /dev/null +++ b/fluent/src/desktopMain/kotlin/io/github/composefluent/component/TimePicker.desktop.kt @@ -0,0 +1,23 @@ +package io.github.composefluent.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.datetime.LocalTime + +@Composable +actual fun TimePicker( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier, + is12hour: Boolean, + disabled: Boolean +) { + TimePickerImpl( + value = value, + onValueChange = onValueChange, + modifier = modifier, + is12hour = is12hour, + disabled = disabled, + userScrollEnabled = false + ) +} \ No newline at end of file diff --git a/fluent/src/iosMain/kotlin/io/github/composefluent/component/TimePicker.ios.kt b/fluent/src/iosMain/kotlin/io/github/composefluent/component/TimePicker.ios.kt new file mode 100644 index 00000000..dc7c408c --- /dev/null +++ b/fluent/src/iosMain/kotlin/io/github/composefluent/component/TimePicker.ios.kt @@ -0,0 +1,21 @@ +package io.github.composefluent.component + +import androidx.compose.runtime.Composable + +@Composable +actual fun TimePicker( + value: kotlinx.datetime.LocalTime?, + onValueChange: (kotlinx.datetime.LocalTime?) -> Unit, + modifier: androidx.compose.ui.Modifier, + is12hour: Boolean, + disabled: Boolean +) { + TimePickerImpl( + value = value, + onValueChange = onValueChange, + modifier = modifier, + is12hour = is12hour, + disabled = disabled, + userScrollEnabled = true + ) +} \ No newline at end of file diff --git a/fluent/src/jsAndWasmMain/kotlin/io/github/composefluent/component/TimePicker.jsAndWasm.kt b/fluent/src/jsAndWasmMain/kotlin/io/github/composefluent/component/TimePicker.jsAndWasm.kt new file mode 100644 index 00000000..641380d8 --- /dev/null +++ b/fluent/src/jsAndWasmMain/kotlin/io/github/composefluent/component/TimePicker.jsAndWasm.kt @@ -0,0 +1,23 @@ +package io.github.composefluent.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.datetime.LocalTime + +@Composable +actual fun TimePicker( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier, + is12hour: Boolean, + disabled: Boolean +) { + TimePickerImpl( + value = value, + onValueChange = onValueChange, + modifier = modifier, + is12hour = is12hour, + disabled = disabled, + userScrollEnabled = false + ) +} \ No newline at end of file From 5dfeb41a7265b5d4177d0b27823b51251208ed64 Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 18:05:58 +0800 Subject: [PATCH 06/11] [opt](time-picker) Optimize center item text color inversion logic --- .../composefluent/component/TimePicker.kt | 79 ++++++++++++++----- 1 file changed, 58 insertions(+), 21 deletions(-) diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt index 77584b27..14101c68 100644 --- a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.* import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -32,6 +33,7 @@ import io.github.composefluent.animation.FluentDuration import io.github.composefluent.animation.FluentEasing import kotlinx.coroutines.launch import kotlinx.datetime.LocalTime +import kotlin.math.roundToInt @Composable expect fun TimePicker( @@ -65,10 +67,11 @@ internal fun TimePickerImpl( var candidateSeconds by remember { mutableStateOf(0) } var candidateAmPm by remember { mutableStateOf("AM") } - Column(Modifier.width(300.dp) - // TODO[optimize](time-picker): Should we use acrylic effect? - // If we use acrylic effect, it would be difficult to hide the text below caret buttons - .background(FluentTheme.colors.background.acrylic.default) + Column( + Modifier.width(300.dp) + // TODO[optimize](time-picker): Should we use acrylic effect? + // If we use acrylic effect, it would be difficult to hide the text below caret buttons + .background(FluentTheme.colors.background.acrylic.default) ) { Box { // Base indicator @@ -129,7 +132,13 @@ internal fun TimePickerImpl( if (is12hour) { onValueChange(LocalTime(candidateHour, candidateMinutes, candidateSeconds)) } else { - onValueChange(LocalTime(hour12to24(candidateHour, candidateAmPm == "AM"), candidateMinutes, candidateSeconds)) + onValueChange( + LocalTime( + hour12to24(candidateHour, candidateAmPm == "AM"), + candidateMinutes, + candidateSeconds + ) + ) } open = false }) { @@ -168,10 +177,12 @@ private fun BoxScope.BaseIndicator(is24Hour: Boolean) { Box( Modifier.height(40.dp).fillMaxWidth().align(Alignment.Center) ) { - Box(Modifier.fillMaxSize().padding(horizontal = 6.dp).background( - color = FluentTheme.colors.fillAccent.default, - shape = FluentTheme.shapes.control - )) + Box( + Modifier.fillMaxSize().padding(horizontal = 6.dp).background( + color = FluentTheme.colors.fillAccent.default, + shape = FluentTheme.shapes.control + ) + ) Row { Spacer(Modifier.weight(1f)) Box( @@ -191,13 +202,19 @@ private fun BoxScope.BaseIndicator(is24Hour: Boolean) { } @Composable -private fun TimePickerButton(modifier: Modifier, value: LocalTime?, is12Hour: Boolean, disabled: Boolean, onClick: () -> Unit) { +private fun TimePickerButton( + modifier: Modifier, + value: LocalTime?, + is12Hour: Boolean, + disabled: Boolean, + onClick: () -> Unit +) { Button( modifier = modifier.width(300.dp), onClick = onClick, disabled = disabled ) { - val labelColor = when { + val labelColor = when { disabled -> FluentTheme.colors.text.text.tertiary value == null -> FluentTheme.colors.text.text.secondary else -> FluentTheme.colors.text.text.primary @@ -254,14 +271,15 @@ private fun InfiniteWheelPicker( else 0 } val centerOffset = (visibleItemsCount - 1) / 2 + val actualCenterOffset = if (ring) centerOffset else 0 val contentPadding = if (items.size < visibleItemsCount) itemHeight * (visibleItemsCount / 2) else 0.dp // Set the initial position to the center of the list val listState = rememberLazyListState( initialFirstVisibleItemIndex = - if (ring) - (virtualListSize / 2 - centerOffset + initialValueIndex).coerceAtLeast(0) - else initialValueIndex + if (ring) + (virtualListSize / 2 - centerOffset + initialValueIndex).coerceAtLeast(0) + else initialValueIndex ) // 当前选中的值 @@ -295,10 +313,21 @@ private fun InfiniteWheelPicker( } } - fun next() { scroll(1) } - fun previous() { scroll(-1) } - fun nextPage() { scroll(visibleItemsCount) } - fun previousPage() { scroll(-visibleItemsCount) } + fun next() { + scroll(1) + } + + fun previous() { + scroll(-1) + } + + fun nextPage() { + scroll(visibleItemsCount) + } + + fun previousPage() { + scroll(-visibleItemsCount) + } val focusRequester = remember { FocusRequester() } @@ -370,9 +399,17 @@ private fun InfiniteWheelPicker( } ) { // TODO[optimize](time-picker): Use brush to implement color inversion? - val textColor = - if (selectedValue == itemValue) FluentTheme.colors.text.onAccent.primary - else FluentTheme.colors.text.text.primary + + // Inverse color after scrolling half of the item + val firstOffset = with(LocalDensity.current) { + listState.firstVisibleItemScrollOffset.toDp() + } + val offset = if (firstOffset >= itemHeight / 2) 1 else 0 + val isCentered = (listState.firstVisibleItemIndex + actualCenterOffset + offset) == index + + val textColor = if (isCentered) FluentTheme.colors.text.onAccent.primary + else FluentTheme.colors.text.text.primary + Text( text = itemValue, style = FluentTheme.typography.body, From fd7aef8e0e1973db3944c9dac856d2e1e543c536 Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 18:10:53 +0800 Subject: [PATCH 07/11] [fix](time-picker) Fix crash on clicking am/pm --- .../kotlin/io/github/composefluent/component/TimePicker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt index 14101c68..27d8cb82 100644 --- a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt @@ -394,7 +394,7 @@ private fun InfiniteWheelPicker( onClick = { // Select this item scrollScope.launch { - listState.animateScrollToItem(index - centerOffset) + listState.animateScrollToItem(index - actualCenterOffset) } } ) { From bd4776ba8a75ff45b13da130249792329154e982 Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 18:29:38 +0800 Subject: [PATCH 08/11] [fix](time-picker) Fix error logic on 12-hour time --- .../io/github/composefluent/component/TimePicker.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt index 27d8cb82..27f5ba0b 100644 --- a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt @@ -33,7 +33,6 @@ import io.github.composefluent.animation.FluentDuration import io.github.composefluent.animation.FluentEasing import kotlinx.coroutines.launch import kotlinx.datetime.LocalTime -import kotlin.math.roundToInt @Composable expect fun TimePicker( @@ -83,7 +82,10 @@ internal fun TimePickerImpl( Box(Modifier.weight(1f)) { InfiniteWheelPicker( items = if (is12hour) hours12 else hours24, - initialValue = value?.hour?.toString(), + initialValue = value?.let { + if (is12hour) hour24to12(it.hour) + else it.hour + }?.toString(), onSelectedValueChange = { candidateHour = it.toInt() }, visibleItemsCount = 9, ring = true, @@ -130,8 +132,6 @@ internal fun TimePickerImpl( modifier = Modifier.padding(4.dp).height(36.dp).weight(1f), onClick = { if (is12hour) { - onValueChange(LocalTime(candidateHour, candidateMinutes, candidateSeconds)) - } else { onValueChange( LocalTime( hour12to24(candidateHour, candidateAmPm == "AM"), @@ -139,6 +139,8 @@ internal fun TimePickerImpl( candidateSeconds ) ) + } else { + onValueChange(LocalTime(candidateHour, candidateMinutes, candidateSeconds)) } open = false }) { @@ -285,7 +287,7 @@ private fun InfiniteWheelPicker( // 当前选中的值 val selectedValue by remember { derivedStateOf { - val centerIndex = listState.firstVisibleItemIndex + centerOffset + val centerIndex = listState.firstVisibleItemIndex + actualCenterOffset items[centerIndex % items.size] } } From 85ace94d900d4e6b5d11269dad2fa6c75605b2b2 Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 18:30:05 +0800 Subject: [PATCH 09/11] [feat](time-picker) Makes TimePicker experimental --- .../kotlin/io/github/composefluent/component/TimePicker.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt index 27f5ba0b..12430f53 100644 --- a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceAtLeast +import io.github.composefluent.ExperimentalFluentApi import io.github.composefluent.FluentTheme import io.github.composefluent.animation.FluentDuration import io.github.composefluent.animation.FluentEasing @@ -35,6 +36,7 @@ import kotlinx.coroutines.launch import kotlinx.datetime.LocalTime @Composable +@ExperimentalFluentApi expect fun TimePicker( value: LocalTime?, onValueChange: (LocalTime?) -> Unit, From 1226e02faae835ece757885b76b977d9ac78c45f Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 18:32:01 +0800 Subject: [PATCH 10/11] [feat](gallery) Add TimePickerScreen --- .../screen/datetime/TimePickerScreen.kt | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 gallery/src/commonMain/kotlin/io/github/composefluent/gallery/screen/datetime/TimePickerScreen.kt diff --git a/gallery/src/commonMain/kotlin/io/github/composefluent/gallery/screen/datetime/TimePickerScreen.kt b/gallery/src/commonMain/kotlin/io/github/composefluent/gallery/screen/datetime/TimePickerScreen.kt new file mode 100644 index 00000000..24323b21 --- /dev/null +++ b/gallery/src/commonMain/kotlin/io/github/composefluent/gallery/screen/datetime/TimePickerScreen.kt @@ -0,0 +1,67 @@ +@file:OptIn(ExperimentalFluentApi::class) + +package io.github.composefluent.gallery.screen.datetime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import io.github.composefluent.ExperimentalFluentApi +import io.github.composefluent.component.TimePicker +import io.github.composefluent.gallery.annotation.Component +import io.github.composefluent.gallery.annotation.Sample +import io.github.composefluent.gallery.component.ComponentPagePath +import io.github.composefluent.gallery.component.GalleryPage +import io.github.composefluent.source.generated.FluentSourceFile +import kotlinx.datetime.LocalTime + +@Component( + description = "A control that lets a user pick a time value." +) +@Composable +fun TimePickerScreen() { + GalleryPage( + title = "TimePicker", + description = "Use a TimePicker to let users set a time in your app, for example to set a reminder. The TimePicker displays three controls for hour, minute, and AM/PM. These controls are easy to use with touch or mouse.", + componentPath = FluentSourceFile.TimePicker, + galleryPath = ComponentPagePath.TimePickerScreen + ) { + + Section( + title = "A TimePicker using a 12-hour clock", + sourceCode = sourceCodeOfTimePicker12Sample + ) { + TimePicker12Sample() + } + Section( + title = "A TimePicker using a 24-hour clock", + sourceCode = sourceCodeOfTimePicker24Sample + ) { + TimePicker24Sample() + } + + } +} + +@Sample +@Composable +private fun TimePicker12Sample() { + var value by remember { mutableStateOf(null) } + TimePicker( + value = value, + onValueChange = { value = it }, + is12hour = true + ) +} + +@Sample +@Composable +private fun TimePicker24Sample() { + var value by remember { mutableStateOf(null) } + TimePicker( + value = value, + onValueChange = { value = it }, + is12hour = false + ) +} \ No newline at end of file From 9ede41bee0796c36b6df0d814d32179cb28293fb Mon Sep 17 00:00:00 2001 From: KonYaco Date: Wed, 23 Jul 2025 18:43:39 +0800 Subject: [PATCH 11/11] [deps] Add kotlinx-datetime dependency to gallery --- gallery/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/gallery/build.gradle.kts b/gallery/build.gradle.kts index 698e0364..b354b93b 100644 --- a/gallery/build.gradle.kts +++ b/gallery/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { implementation(project(":fluent-icons-extended")) implementation(compose.uiUtil) implementation(libs.highlights) + implementation(libs.kotlinx.datetime) implementation(project(":source-generated")) } kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")