From 3bef8cc3f18db199e3a8ebf41325252991647283 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:51:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=B6=E9=97=B4=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=E4=BC=98=E5=8C=96=20=E2=80=94=E2=80=94=20=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E5=8D=95=E5=BC=B9=E7=AA=97=E9=80=89=E6=8B=A9=EF=BC=88?= =?UTF-8?q?DateRangePicker=EF=BC=89+=20=E6=89=93=E5=BC=80=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=20+=20=E5=A4=A9/=E6=9C=88=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=E7=B2=BE=E7=AE=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/dashboard/DateRangePickerDialog.kt | 79 +++++++++++++++++++ .../token/ui/dashboard/UsageChartViewModel.kt | 13 ++- .../token/ui/dashboard/UsageDataScreen.kt | 72 +++++++++++------ .../token/ui/dashboard/UsageDetailScreen.kt | 68 ++++++++-------- .../token/ui/dashboard/UsageOverviewScreen.kt | 36 ++++++--- app/src/main/res/values-b+zh+Hans/strings.xml | 1 + app/src/main/res/values-b+zh+Hant/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 8 files changed, 202 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/ui/dashboard/DateRangePickerDialog.kt diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DateRangePickerDialog.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DateRangePickerDialog.kt new file mode 100644 index 0000000..d422fbe --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DateRangePickerDialog.kt @@ -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() diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt index c7bdc95..ea4ee60 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt @@ -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 = emptyList(), /** 当前显示范围内实际出现过的模型(图例只显示这些) */ val rangeModels: List = emptyList(), + /** 自定义选择的回显值(选择器打开时预填用) */ + val customDay: LocalDate? = null, + val customMonth: LocalDate? = null, + val customRange: Pair? = null, val selectedModels: Set = emptySet(), // empty = all val buckets: List = emptyList(), val useUtc8: Boolean = false, diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt index 93b1f5f..4d79e6e 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt @@ -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(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 diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt index 2a058d2..261073c 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt @@ -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(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 = {}) } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt index 463adda..a86c710 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt @@ -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" diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 791e333..9ff2ddb 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -341,6 +341,7 @@ %1$d次 选择日期 选择月份(任意一天) + 日期范围 开始日期 结束日期 消费明细 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index b92539f..f6a2db7 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -341,6 +341,7 @@ %1$d次 選擇日期 選擇月份(任意一天) + 日期範圍 開始日期 結束日期 消費明細 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43690cd..6c71e04 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -344,6 +344,7 @@ %1$d calls Pick a date Pick a month (any day) + Date range Start date End date Spending breakdown