feat: 时间选择器优化 —— 范围单弹窗选择(DateRangePicker)+ 打开回显 + 天/月选择器精简

This commit is contained in:
WaterRain 2026-09-11 07:51:49 +00:00
parent 777860024d
commit 3bef8cc3f1
No known key found for this signature in database
8 changed files with 202 additions and 69 deletions

View File

@ -0,0 +1,79 @@
package com.rainy.token.ui.dashboard
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.rainy.token.R
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
/**
* 日期范围选择弹窗 —— 单弹窗内一次选出起止日期(连续高亮)。
*
* - 状态通过 "if (show) Dialog(...)" 条件组合创建:每次打开都会用最新预填值重新初始化;
* - 预填毫秒一律按 UTC 零点解释(与项目既有 UTC 语义一致);
* - 两端日期齐备前,确认按钮保持禁用(M3 范围选择:同一天点两次 = 单日区间)。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DateRangePickerDialog(
title: String,
initialStart: LocalDate?,
initialEnd: LocalDate?,
onConfirm: (LocalDate, LocalDate) -> Unit,
onDismiss: () -> Unit
) {
// 预填数据 sanitize:无起点时忽略终点;终点早于起点时忽略终点
val sanitizedEnd = initialEnd?.takeIf { initialStart != null && !it.isBefore(initialStart) }
val state = rememberDateRangePickerState(
initialSelectedStartDateMillis = initialStart?.toUtcMillis(),
initialSelectedEndDateMillis = sanitizedEnd?.toUtcMillis()
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
val complete = state.selectedStartDateMillis != null && state.selectedEndDateMillis != null
TextButton(
onClick = {
val startMillis = state.selectedStartDateMillis
val endMillis = state.selectedEndDateMillis
if (startMillis != null && endMillis != null) {
onConfirm(startMillis.toUtcLocalDate(), endMillis.toUtcLocalDate())
}
},
enabled = complete
) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
) {
DateRangePicker(
state = state,
modifier = Modifier.fillMaxWidth().height(500.dp).padding(16.dp),
title = { Text(title) },
showModeToggle = false
)
}
}
private fun LocalDate.toUtcMillis(): Long =
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private fun Long.toUtcLocalDate(): LocalDate =
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()

View File

@ -167,20 +167,21 @@ class UsageChartViewModel @Inject constructor(
}
}
fun setCustomDay(date: LocalDate) {
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY) }
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY, customDay = date) }
_customDay = date
load()
}
fun setCustomMonth(monthDate: LocalDate) {
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY) }
_customMonth = monthDate.withDayOfMonth(1)
val month = monthDate.withDayOfMonth(1)
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY, customMonth = month) }
_customMonth = month
load()
}
fun setCustomRange(fromDate: LocalDate, toDate: LocalDate) {
if (toDate.isBefore(fromDate)) return
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY) }
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY, customRange = fromDate to toDate) }
_customRange = fromDate to toDate
load()
}
@ -268,6 +269,10 @@ data class ChartUiState(
val allModels: List<String> = emptyList(),
/** 当前显示范围内实际出现过的模型(图例只显示这些) */
val rangeModels: List<String> = emptyList(),
/** 自定义选择的回显值(选择器打开时预填用) */
val customDay: LocalDate? = null,
val customMonth: LocalDate? = null,
val customRange: Pair<LocalDate, LocalDate>? = null,
val selectedModels: Set<String> = emptySet(), // empty = all
val buckets: List<ChartBucket> = emptyList(),
val useUtc8: Boolean = false,

View File

@ -59,6 +59,7 @@ import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.Date
import java.util.Locale
@ -82,10 +83,7 @@ fun UsageDataScreen(
var timeMenuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) }
var showStartPicker by remember { mutableStateOf(false) }
var showEndPicker by remember { mutableStateOf(false) }
var customStartMs by remember { mutableStateOf(0L) }
var customEndMs by remember { mutableStateOf(0L) }
var showRangePicker by remember { mutableStateOf(false) }
var showCustomDayPicker by remember { mutableStateOf(false) }
var showCustomMonthPicker by remember { mutableStateOf(false) }
var rawRecord by remember { mutableStateOf<UsageRecord?>(null) }
@ -127,7 +125,7 @@ fun UsageDataScreen(
timeMenuExpanded = false; showCustomMonthPicker = true
})
DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_range)) }, onClick = {
timeMenuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L))
timeMenuExpanded = false; showRangePicker = true
})
}
}
@ -155,11 +153,8 @@ fun UsageDataScreen(
}
}
// 自定义时间范围
if (state.timeFilter is TimeFilter.Custom && (state.timeFilter as TimeFilter.Custom).from == 0L) {
CustomTimeRangeRow(customStartMs, customEndMs,
{ showStartPicker = true }, { showEndPicker = true },
{ viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) })
// 自定义时间范围:确认选择后显示 UTC 说明
if (state.timeFilter is TimeFilter.Custom) {
Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
@ -228,20 +223,43 @@ fun UsageDataScreen(
}
// 日期选择器
if (showCustomDayPicker) DateTimePickerDialog(stringResource(R.string.date_select_day), { ms ->
val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli()
viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1)); showCustomDayPicker = false
}, { showCustomDayPicker = false })
if (showCustomMonthPicker) DateTimePickerDialog(stringResource(R.string.date_select_month), { ms ->
val utc = ZoneOffset.UTC; val ld = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate()
val msStart = ld.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
val msEnd = ld.withDayOfMonth(ld.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1
viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd)); showCustomMonthPicker = false
}, { showCustomMonthPicker = false })
if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { ms ->
viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, ms)); showEndPicker = false
}, { showEndPicker = false })
if (showCustomDayPicker) DateOnlyPickerDialog(
title = stringResource(R.string.date_select_day),
initialDate = (state.timeFilter as? TimeFilter.Custom)?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
onConfirm = { date ->
val ds = date.toUtcStartOfDayMillis()
viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1))
showCustomDayPicker = false
},
onDismiss = { showCustomDayPicker = false }
)
if (showCustomMonthPicker) DateOnlyPickerDialog(
title = stringResource(R.string.date_select_month),
initialDate = (state.timeFilter as? TimeFilter.Custom)?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
onConfirm = { date ->
val utc = ZoneOffset.UTC
val msStart = date.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
val msEnd = date.withDayOfMonth(date.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1
viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd))
showCustomMonthPicker = false
},
onDismiss = { showCustomMonthPicker = false }
)
if (showRangePicker) {
val current = state.timeFilter as? TimeFilter.Custom
DateRangePickerDialog(
title = stringResource(R.string.date_pick_range),
initialStart = current?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
initialEnd = current?.to?.takeIf { current.from > 0 }?.toUtcLocalDate(),
onConfirm = { from, to ->
val fromMs = from.toUtcStartOfDayMillis()
val toMs = to.toUtcStartOfDayMillis()
viewModel.setTimeFilter(TimeFilter.Custom(fromMs, toMs + 86400_000L - 1))
showRangePicker = false
},
onDismiss = { showRangePicker = false }
)
}
// 原始数据弹窗
rawRecord?.let { record ->
@ -313,6 +331,12 @@ private fun RawField(label: String, value: String) {
}
}
private fun Long.toUtcLocalDate(): LocalDate =
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()
private fun LocalDate.toUtcStartOfDayMillis(): Long =
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private fun formatInputWithCache(record: UsageRecord): String {
val input = record.inputTokens + record.cacheReadTokens
val cache = record.cacheReadTokens

View File

@ -97,9 +97,7 @@ fun UsageDetailScreen(
var modelExpanded by remember { mutableStateOf(false) }
var showCustomDayPicker by remember { mutableStateOf(false) }
var showCustomMonthPicker by remember { mutableStateOf(false) }
var showCustomRangeStart by remember { mutableStateOf(false) }
var showCustomRangeEnd by remember { mutableStateOf(false) }
var customRangeStartDate by remember { mutableStateOf<LocalDate?>(null) }
var showCustomRangePicker by remember { mutableStateOf(false) }
var showCostDetail by remember { mutableStateOf(false) }
var showReqDetail by remember { mutableStateOf(false) }
var showTokenDetail by remember { mutableStateOf(false) }
@ -170,7 +168,7 @@ fun UsageDetailScreen(
when (g) {
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangePicker = true
else -> viewModel.setGranularity(g)
}
})
@ -321,31 +319,35 @@ fun UsageDetailScreen(
}
}
// 自定义日期选择器
if (showCustomDayPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_day), { date ->
viewModel.setCustomDay(date)
showCustomDayPicker = false
}, { showCustomDayPicker = false })
if (showCustomMonthPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_month), { date ->
viewModel.setCustomMonth(date)
showCustomMonthPicker = false
}, { showCustomMonthPicker = false })
if (showCustomRangeStart) DateOnlyPickerDialog(stringResource(R.string.date_pick_start), { date ->
customRangeStartDate = date
showCustomRangeStart = false
showCustomRangeEnd = true
}, { showCustomRangeStart = false })
if (showCustomRangeEnd) {
val startDate = customRangeStartDate
DateOnlyPickerDialog(
title = stringResource(R.string.date_pick_end),
onConfirm = { endDate ->
if (startDate != null && !endDate.isBefore(startDate)) {
viewModel.setCustomRange(startDate, endDate)
showCustomRangeEnd = false
}
if (showCustomDayPicker) DateOnlyPickerDialog(
title = stringResource(R.string.date_select_day),
initialDate = state.customDay,
onConfirm = { date ->
viewModel.setCustomDay(date)
showCustomDayPicker = false
},
onDismiss = { showCustomDayPicker = false }
)
if (showCustomMonthPicker) DateOnlyPickerDialog(
title = stringResource(R.string.date_select_month),
initialDate = state.customMonth,
onConfirm = { date ->
viewModel.setCustomMonth(date)
showCustomMonthPicker = false
},
onDismiss = { showCustomMonthPicker = false }
)
if (showCustomRangePicker) {
val currentRange = state.customRange
DateRangePickerDialog(
title = stringResource(R.string.date_pick_range),
initialStart = currentRange?.first,
initialEnd = currentRange?.second,
onConfirm = { from, to ->
viewModel.setCustomRange(from, to)
showCustomRangePicker = false
},
onDismiss = { showCustomRangeEnd = false },
minDate = startDate
onDismiss = { showCustomRangePicker = false }
)
}
val models = state.rangeModels
@ -465,8 +467,9 @@ internal fun CustomTimeRangeRow(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DateOnlyPickerDialog(
internal fun DateOnlyPickerDialog(
title: String,
initialDate: LocalDate? = null,
onConfirm: (LocalDate) -> Unit,
onDismiss: () -> Unit,
minDate: LocalDate? = null
@ -480,7 +483,10 @@ private fun DateOnlyPickerDialog(
}
}
}
val dateState = rememberDatePickerState(selectableDates = selectableDates)
val dateState = rememberDatePickerState(
initialSelectedDateMillis = initialDate?.atStartOfDay(utc)?.toInstant()?.toEpochMilli(),
selectableDates = selectableDates
)
DatePickerDialog(
onDismissRequest = onDismiss,
@ -512,7 +518,7 @@ private fun DateOnlyPickerDialog(
color = InkMuted
)
}
DatePicker(state = dateState)
DatePicker(state = dateState, title = {})
}
}
}

View File

@ -52,6 +52,9 @@ import com.rainy.token.data.local.OverviewStats
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.Date
import java.util.Locale
@ -74,10 +77,7 @@ fun UsageOverviewScreen(
var menuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) }
var showStartPicker by remember { mutableStateOf(false) }
var showEndPicker by remember { mutableStateOf(false) }
var customStartMs by remember { mutableStateOf(0L) }
var customEndMs by remember { mutableStateOf(0L) }
var showRangePicker by remember { mutableStateOf(false) }
Scaffold(
containerColor = Color.Transparent,
@ -115,16 +115,13 @@ fun UsageOverviewScreen(
}
DropdownMenuItem(
text = { Text(stringResource(R.string.time_custom)) },
onClick = { menuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) }
onClick = { menuExpanded = false; showRangePicker = true }
)
}
}
}
if (uiState.timeFilter is TimeFilter.Custom) {
item {
CustomTimeRangeRow(customStartMs, customEndMs,
{ showStartPicker = true }, { showEndPicker = true },
{ viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) })
Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
@ -179,8 +176,21 @@ fun UsageOverviewScreen(
}
item { Spacer(Modifier.height(24.dp)) }
}
if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { customEndMs = it; showEndPicker = false }, { showEndPicker = false })
if (showRangePicker) {
val current = uiState.timeFilter as? TimeFilter.Custom
DateRangePickerDialog(
title = stringResource(R.string.date_pick_range),
initialStart = current?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
initialEnd = current?.to?.takeIf { current.from > 0 }?.toUtcLocalDate(),
onConfirm = { from, to ->
val fromMs = from.toUtcStartOfDayMillis()
val toMs = to.toUtcStartOfDayMillis()
viewModel.setTimeFilter(TimeFilter.Custom(fromMs, toMs + 86400_000L - 1))
showRangePicker = false
},
onDismiss = { showRangePicker = false }
)
}
}
}
@ -281,6 +291,12 @@ fun UsageOverviewScreen(
}
}
private fun Long.toUtcLocalDate(): LocalDate =
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()
private fun LocalDate.toUtcStartOfDayMillis(): Long =
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private fun formatTokenCount(tokens: Long): String = when {
tokens >= 1_000_000 -> "${"%.1f".format(Locale.US, tokens / 1_000_000.0)}M"
tokens >= 1_000 -> "${"%.1f".format(Locale.US, tokens / 1_000.0)}K"

View File

@ -341,6 +341,7 @@
<string name="count_times">%1$d次</string>
<string name="date_select_day">选择日期</string>
<string name="date_select_month">选择月份(任意一天)</string>
<string name="date_pick_range">日期范围</string>
<string name="date_pick_start">开始日期</string>
<string name="date_pick_end">结束日期</string>
<string name="chart_detail_cost">消费明细</string>

View File

@ -341,6 +341,7 @@
<string name="count_times">%1$d次</string>
<string name="date_select_day">選擇日期</string>
<string name="date_select_month">選擇月份(任意一天)</string>
<string name="date_pick_range">日期範圍</string>
<string name="date_pick_start">開始日期</string>
<string name="date_pick_end">結束日期</string>
<string name="chart_detail_cost">消費明細</string>

View File

@ -344,6 +344,7 @@
<string name="count_times">%1$d calls</string>
<string name="date_select_day">Pick a date</string>
<string name="date_select_month">Pick a month (any day)</string>
<string name="date_pick_range">Date range</string>
<string name="date_pick_start">Start date</string>
<string name="date_pick_end">End date</string>
<string name="chart_detail_cost">Spending breakdown</string>