diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt index b259462..0287ae9 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -207,12 +207,21 @@ fun CalendarMonthView( val onToday = remember(viewModel, today) { { viewModel.selectDate(today) } } + val onMonthYearSelect = remember(viewModel, today) { + { year: Int, month: Int -> + @Suppress("DEPRECATION") + val date = if (year == today.year && today.month.number == month) today + else LocalDate(year, month, 1) + viewModel.selectDate(date) + } + } MonthHeader( year = currentYear, month = currentMonth, weekNumber = weekNumber, showToday = selectedDate != today, - onToday = onToday + onToday = onToday, + onMonthYearSelect = onMonthYearSelect ) WeekdayHeader( modifier = Modifier.fillMaxWidth().padding(bottom = ROW_PADDING_DP.dp) diff --git a/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt b/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt index 7db15ff..316eb53 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt @@ -19,6 +19,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,11 +32,14 @@ import androidx.compose.ui.unit.sp /** * 月份标题栏,显示"年月"文字和 ISO 周号。 * + * 点击年月文字弹出滚轮选择器,可快速跳转到任意年月。 + * * @param year 年份 * @param month 月份(1-12) * @param weekNumber 当前 ISO 周号 * @param showToday 是否显示「今天」按钮(当 selectedDate ≠ today 时) * @param onToday 点击「今天」按钮跳转今天 + * @param onMonthYearSelect 年月选择回调 * @param modifier 外部布局修饰符 */ @Composable @@ -43,8 +49,23 @@ fun MonthHeader( weekNumber: Int, showToday: Boolean, onToday: (() -> Unit)? = null, + onMonthYearSelect: ((year: Int, month: Int) -> Unit)? = null, modifier: Modifier = Modifier ) { + var showPicker by remember { mutableStateOf(false) } + + if (showPicker && onMonthYearSelect != null) { + MonthYearPickerDialog( + currentYear = year, + currentMonth = month, + onConfirm = { y, m -> + onMonthYearSelect(y, m) + showPicker = false + }, + onDismiss = { showPicker = false } + ) + } + Row( modifier = modifier .fillMaxWidth() @@ -66,7 +87,13 @@ fun MonthHeader( Text( text = "${y}年${m}月", color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, + modifier = if (onMonthYearSelect != null) { + Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { showPicker = true } + .padding(horizontal = 4.dp, vertical = 2.dp) + } else Modifier ) } Spacer(modifier = Modifier.width(6.dp)) diff --git a/core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt b/core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt new file mode 100644 index 0000000..ab5e3ad --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt @@ -0,0 +1,122 @@ +package plus.rua.project.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +private val Years = (1970..2100).map { "${it}年" } +private val Months = (1..12).map { "${it}月" } + +/** + * 年月滚轮选择器弹窗。 + * + * 左侧年份滚轮 + 右侧月份滚轮,每次滚动触发触觉反馈。 + * + * @param currentYear 当前年份 + * @param currentMonth 当前月份(1-12) + * @param onConfirm 确认回调,参数为 (year, month) + * @param onDismiss 关闭回调 + */ +@Composable +fun MonthYearPickerDialog( + currentYear: Int, + currentMonth: Int, + onConfirm: (year: Int, month: Int) -> Unit, + onDismiss: () -> Unit +) { + var selectedYear by remember { mutableIntStateOf(currentYear) } + var selectedMonth by remember { mutableIntStateOf(currentMonth) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("选择年月", style = MaterialTheme.typography.titleMedium) + }, + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + WheelPicker( + items = Years, + selectedIndex = selectedYear - 1970, + onSelectedChange = { selectedYear = it + 1970 }, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(16.dp)) + WheelPicker( + items = Months, + selectedIndex = selectedMonth - 1, + onSelectedChange = { selectedMonth = it + 1 }, + modifier = Modifier.weight(1f) + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selectedYear, selectedMonth) }) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + } + ) +} + +/** + * 年份滚轮选择器弹窗(用于年视图)。 + * + * @param currentYear 当前年份 + * @param onConfirm 确认回调,参数为 year + * @param onDismiss 关闭回调 + */ +@Composable +fun YearPickerDialog( + currentYear: Int, + onConfirm: (year: Int) -> Unit, + onDismiss: () -> Unit +) { + var selectedYear by remember { mutableIntStateOf(currentYear) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("选择年份", style = MaterialTheme.typography.titleMedium) + }, + text = { + WheelPicker( + items = Years, + selectedIndex = selectedYear - 1970, + onSelectedChange = { selectedYear = it + 1970 }, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(selectedYear) }) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + } + ) +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt b/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt new file mode 100644 index 0000000..6e4dae8 --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt @@ -0,0 +1,151 @@ +package plus.rua.project.ui + +import android.os.Build +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.roundToInt + +private val ItemHeight = 48.dp +private const val VisibleItemCount = 5 +private val WheelHeight = ItemHeight * VisibleItemCount + +/** + * 通用滚轮选择器,支持惯性吸附和触觉反馈。 + * + * @param items 显示的项目列表 + * @param selectedIndex 当前选中项索引 + * @param onSelectedChange 选中项变化回调 + * @param modifier 外部布局修饰符 + * @param itemContent 单个项目渲染,[isSelected] 为 true 表示中心选中项 + */ +@Composable +fun WheelPicker( + items: List, + selectedIndex: Int, + onSelectedChange: (Int) -> Unit, + modifier: Modifier = Modifier, + itemContent: @Composable (index: Int, item: String, isSelected: Boolean) -> Unit = { _, item, isSelected -> + Text( + text = item, + color = if (isSelected) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + fontSize = if (isSelected) 20.sp else 16.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + style = LocalTextStyle.current + ) + } +) { + val paddingItems = VisibleItemCount / 2 + val totalItems = items.size + paddingItems * 2 + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = (selectedIndex - paddingItems).coerceAtLeast(0) + ) + val coroutineScope = rememberCoroutineScope() + val view = LocalView.current + + fun centerForLayoutIndex(layoutIndex: Int): Int = layoutIndex - paddingItems + + fun layoutIndexForCenter(center: Int): Int = center + paddingItems + + // 检测中心选中项变化 → 触觉反馈 + val currentCenter by remember { + derivedStateOf { + val viewportCenter = listState.layoutInfo.viewportSize.height / 2f + listState.layoutInfo.visibleItemsInfo.minByOrNull { + abs(it.offset + it.size / 2f - viewportCenter) + }?.index?.let { centerForLayoutIndex(it) } ?: -1 + } + } + + LaunchedEffect(currentCenter) { + if (currentCenter in items.indices && currentCenter != selectedIndex) { + onSelectedChange(currentCenter) + performHapticFeedback(view) + } + } + + // 初始滚动到选中项 + LaunchedEffect(selectedIndex) { + val target = layoutIndexForCenter(selectedIndex) + if (centerForLayoutIndex(listState.firstVisibleItemIndex) != selectedIndex) { + listState.scrollToItem((target - paddingItems).coerceAtLeast(0)) + } + } + + // 滚动停止后吸附到最近项 + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress } + .collect { scrolling -> + if (!scrolling) { + val target = layoutIndexForCenter(currentCenter.coerceIn(0, items.lastIndex)) + val current = listState.firstVisibleItemIndex + paddingItems + if (target != current) { + coroutineScope.launch { + listState.animateScrollToItem((target - paddingItems).coerceAtLeast(0)) + } + } + } + } + } + + val snapLayoutInfoProvider = remember(listState) { + SnapLayoutInfoProvider(listState) + } + + LazyColumn( + state = listState, + modifier = modifier.height(WheelHeight), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + horizontalAlignment = Alignment.CenterHorizontally, + userScrollEnabled = true + ) { + items(totalItems) { layoutIndex -> + val centerIndex = centerForLayoutIndex(layoutIndex) + val isValid = centerIndex in items.indices + Box( + modifier = Modifier + .height(ItemHeight) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + if (isValid) { + itemContent(centerIndex, items[centerIndex], centerIndex == currentCenter) + } + } + } + } +} + +private fun performHapticFeedback(view: android.view.View) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK) + } else { + @Suppress("DEPRECATION") + view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK) + } +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt index cc2fb8a..fb3b8a7 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt @@ -351,6 +351,19 @@ fun YearHeader( onYearChange: (Int) -> Unit, modifier: Modifier = Modifier ) { + var showPicker by remember { mutableStateOf(false) } + + if (showPicker) { + YearPickerDialog( + currentYear = year, + onConfirm = { y -> + onYearChange(y) + showPicker = false + }, + onDismiss = { showPicker = false } + ) + } + Column( modifier = modifier .fillMaxWidth() @@ -373,7 +386,11 @@ fun YearHeader( text = "${y}年", color = MaterialTheme.colorScheme.onBackground, style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { showPicker = true } + .padding(horizontal = 4.dp, vertical = 2.dp) ) } Spacer(modifier = Modifier.weight(1f))