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) { 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 _customDay = date
load() load()
} }
fun setCustomMonth(monthDate: LocalDate) { fun setCustomMonth(monthDate: LocalDate) {
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY) } val month = monthDate.withDayOfMonth(1)
_customMonth = monthDate.withDayOfMonth(1) _state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY, customMonth = month) }
_customMonth = month
load() load()
} }
fun setCustomRange(fromDate: LocalDate, toDate: LocalDate) { fun setCustomRange(fromDate: LocalDate, toDate: LocalDate) {
if (toDate.isBefore(fromDate)) return 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 _customRange = fromDate to toDate
load() load()
} }
@ -268,6 +269,10 @@ data class ChartUiState(
val allModels: List<String> = emptyList(), val allModels: List<String> = emptyList(),
/** 当前显示范围内实际出现过的模型(图例只显示这些) */ /** 当前显示范围内实际出现过的模型(图例只显示这些) */
val rangeModels: 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 selectedModels: Set<String> = emptySet(), // empty = all
val buckets: List<ChartBucket> = emptyList(), val buckets: List<ChartBucket> = emptyList(),
val useUtc8: Boolean = false, 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 com.rainy.token.ui.theme.StrawberryPink
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -82,10 +83,7 @@ fun UsageDataScreen(
var timeMenuExpanded by remember { mutableStateOf(false) } var timeMenuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) } var modelMenuExpanded by remember { mutableStateOf(false) }
var showStartPicker by remember { mutableStateOf(false) } var showRangePicker by remember { mutableStateOf(false) }
var showEndPicker by remember { mutableStateOf(false) }
var customStartMs by remember { mutableStateOf(0L) }
var customEndMs by remember { mutableStateOf(0L) }
var showCustomDayPicker by remember { mutableStateOf(false) } var showCustomDayPicker by remember { mutableStateOf(false) }
var showCustomMonthPicker by remember { mutableStateOf(false) } var showCustomMonthPicker by remember { mutableStateOf(false) }
var rawRecord by remember { mutableStateOf<UsageRecord?>(null) } var rawRecord by remember { mutableStateOf<UsageRecord?>(null) }
@ -127,7 +125,7 @@ fun UsageDataScreen(
timeMenuExpanded = false; showCustomMonthPicker = true timeMenuExpanded = false; showCustomMonthPicker = true
}) })
DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_range)) }, onClick = { 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(
} }
} }
// 自定义时间范围 // 自定义时间范围:确认选择后显示 UTC 说明
if (state.timeFilter is TimeFilter.Custom && (state.timeFilter as TimeFilter.Custom).from == 0L) { if (state.timeFilter is TimeFilter.Custom) {
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()) 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 -> if (showCustomDayPicker) DateOnlyPickerDialog(
val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() title = stringResource(R.string.date_select_day),
viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1)); showCustomDayPicker = false initialDate = (state.timeFilter as? TimeFilter.Custom)?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
}, { showCustomDayPicker = false }) onConfirm = { date ->
if (showCustomMonthPicker) DateTimePickerDialog(stringResource(R.string.date_select_month), { ms -> val ds = date.toUtcStartOfDayMillis()
val utc = ZoneOffset.UTC; val ld = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate() viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1))
val msStart = ld.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli() showCustomDayPicker = false
val msEnd = ld.withDayOfMonth(ld.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1 },
viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd)); showCustomMonthPicker = false onDismiss = { showCustomDayPicker = false }
}, { showCustomMonthPicker = false }) )
if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) if (showCustomMonthPicker) DateOnlyPickerDialog(
if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { ms -> title = stringResource(R.string.date_select_month),
viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, ms)); showEndPicker = false initialDate = (state.timeFilter as? TimeFilter.Custom)?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
}, { showEndPicker = false }) 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 -> 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 { private fun formatInputWithCache(record: UsageRecord): String {
val input = record.inputTokens + record.cacheReadTokens val input = record.inputTokens + record.cacheReadTokens
val cache = record.cacheReadTokens val cache = record.cacheReadTokens

View File

@ -97,9 +97,7 @@ fun UsageDetailScreen(
var modelExpanded by remember { mutableStateOf(false) } var modelExpanded by remember { mutableStateOf(false) }
var showCustomDayPicker by remember { mutableStateOf(false) } var showCustomDayPicker by remember { mutableStateOf(false) }
var showCustomMonthPicker by remember { mutableStateOf(false) } var showCustomMonthPicker by remember { mutableStateOf(false) }
var showCustomRangeStart by remember { mutableStateOf(false) } var showCustomRangePicker by remember { mutableStateOf(false) }
var showCustomRangeEnd by remember { mutableStateOf(false) }
var customRangeStartDate by remember { mutableStateOf<LocalDate?>(null) }
var showCostDetail by remember { mutableStateOf(false) } var showCostDetail by remember { mutableStateOf(false) }
var showReqDetail by remember { mutableStateOf(false) } var showReqDetail by remember { mutableStateOf(false) }
var showTokenDetail by remember { mutableStateOf(false) } var showTokenDetail by remember { mutableStateOf(false) }
@ -170,7 +168,7 @@ fun UsageDetailScreen(
when (g) { when (g) {
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangePicker = true
else -> viewModel.setGranularity(g) else -> viewModel.setGranularity(g)
} }
}) })
@ -321,31 +319,35 @@ fun UsageDetailScreen(
} }
} }
// 自定义日期选择器 // 自定义日期选择器
if (showCustomDayPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_day), { date -> if (showCustomDayPicker) DateOnlyPickerDialog(
viewModel.setCustomDay(date) title = stringResource(R.string.date_select_day),
showCustomDayPicker = false initialDate = state.customDay,
}, { showCustomDayPicker = false }) onConfirm = { date ->
if (showCustomMonthPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_month), { date -> viewModel.setCustomDay(date)
viewModel.setCustomMonth(date) showCustomDayPicker = false
showCustomMonthPicker = false },
}, { showCustomMonthPicker = false }) onDismiss = { showCustomDayPicker = false }
if (showCustomRangeStart) DateOnlyPickerDialog(stringResource(R.string.date_pick_start), { date -> )
customRangeStartDate = date if (showCustomMonthPicker) DateOnlyPickerDialog(
showCustomRangeStart = false title = stringResource(R.string.date_select_month),
showCustomRangeEnd = true initialDate = state.customMonth,
}, { showCustomRangeStart = false }) onConfirm = { date ->
if (showCustomRangeEnd) { viewModel.setCustomMonth(date)
val startDate = customRangeStartDate showCustomMonthPicker = false
DateOnlyPickerDialog( },
title = stringResource(R.string.date_pick_end), onDismiss = { showCustomMonthPicker = false }
onConfirm = { endDate -> )
if (startDate != null && !endDate.isBefore(startDate)) { if (showCustomRangePicker) {
viewModel.setCustomRange(startDate, endDate) val currentRange = state.customRange
showCustomRangeEnd = false 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 }, onDismiss = { showCustomRangePicker = false }
minDate = startDate
) )
} }
val models = state.rangeModels val models = state.rangeModels
@ -465,8 +467,9 @@ internal fun CustomTimeRangeRow(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DateOnlyPickerDialog( internal fun DateOnlyPickerDialog(
title: String, title: String,
initialDate: LocalDate? = null,
onConfirm: (LocalDate) -> Unit, onConfirm: (LocalDate) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
minDate: LocalDate? = null 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( DatePickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@ -512,7 +518,7 @@ private fun DateOnlyPickerDialog(
color = InkMuted 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.inkMuted
import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.theme.StrawberryPink
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -74,10 +77,7 @@ fun UsageOverviewScreen(
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) } var modelMenuExpanded by remember { mutableStateOf(false) }
var showStartPicker by remember { mutableStateOf(false) } var showRangePicker by remember { mutableStateOf(false) }
var showEndPicker by remember { mutableStateOf(false) }
var customStartMs by remember { mutableStateOf(0L) }
var customEndMs by remember { mutableStateOf(0L) }
Scaffold( Scaffold(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@ -115,16 +115,13 @@ fun UsageOverviewScreen(
} }
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.time_custom)) }, 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) { if (uiState.timeFilter is TimeFilter.Custom) {
item { 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()) 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)) } item { Spacer(Modifier.height(24.dp)) }
} }
if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) if (showRangePicker) {
if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { customEndMs = it; showEndPicker = false }, { showEndPicker = false }) 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 { private fun formatTokenCount(tokens: Long): String = when {
tokens >= 1_000_000 -> "${"%.1f".format(Locale.US, tokens / 1_000_000.0)}M" 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" 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="count_times">%1$d次</string>
<string name="date_select_day">选择日期</string> <string name="date_select_day">选择日期</string>
<string name="date_select_month">选择月份(任意一天)</string> <string name="date_select_month">选择月份(任意一天)</string>
<string name="date_pick_range">日期范围</string>
<string name="date_pick_start">开始日期</string> <string name="date_pick_start">开始日期</string>
<string name="date_pick_end">结束日期</string> <string name="date_pick_end">结束日期</string>
<string name="chart_detail_cost">消费明细</string> <string name="chart_detail_cost">消费明细</string>

View File

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

View File

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