feat(heatmap): 滑动查看手势扩展到每周与累计视图
- 手势仲裁(长按/慢速拖动=查看锁定、快速=滚动)从每日扩展到三种视图 - 长按(按住不动≥系统长按时长)进入查看:所见即所得格子高亮+读数条 - 每周视图读数条显示周范围(formatWeekRangeText 与浮层同口径) - 累计视图读数条显示累计 token;tap 点击清除残留预览 - 对抗审查修复:up 消费防误弹浮层、判决事件即时消费、溢出省略、防御性校验
This commit is contained in:
parent
962d8f1215
commit
83fc18317f
@ -1,8 +1,11 @@
|
||||
package com.rainy.token.ui.heatmap
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@ -13,6 +16,7 @@ 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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@ -21,6 +25,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -50,6 +55,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@ -81,8 +87,92 @@ private val ChineseMonthNames = listOf(
|
||||
"七月", "八月", "九月", "十月", "十一月", "十二月",
|
||||
)
|
||||
|
||||
// ── 格式化辅助函数(HeatmapScreen 浮层与滑动查看读数条共用)──
|
||||
|
||||
/**
|
||||
* 将 Token 数按中文数量级格式化:
|
||||
* - 亿级(≥1亿):1.9亿
|
||||
* - 万级(≥1万):11.2万 / 1124.5万
|
||||
* - 万以下:原始数字
|
||||
*
|
||||
* 注意:万级先舍入到一位小数再判断是否升亿(如 99999999 → "1.0亿",而非 "10000.0万")。
|
||||
*/
|
||||
internal fun formatTokenChinese(tokens: Long): String {
|
||||
return when {
|
||||
tokens >= 1_0000_0000 -> {
|
||||
val v = tokens / 1_0000_0000.0
|
||||
if (v >= 100) "${v.roundToInt()}亿"
|
||||
else "${"%.1f".format(v)}亿"
|
||||
}
|
||||
tokens >= 1_0000 -> {
|
||||
val v = tokens / 1_0000.0
|
||||
// 先舍入到一位小数,避免 "99999999 → 10000.0万"
|
||||
val rounded = (v * 10).roundToInt() / 10.0
|
||||
if (rounded >= 10000) {
|
||||
// 万级溢出升亿:9999.99万 → 1.0亿
|
||||
val v2 = rounded / 10000.0
|
||||
if (v2 >= 100) "${v2.roundToInt()}亿"
|
||||
else "${"%.1f".format(v2)}亿"
|
||||
} else {
|
||||
"${"%.1f".format(v)}万" // 始终保留一位小数
|
||||
}
|
||||
}
|
||||
else -> "$tokens"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间戳格式化为 "7月9日" 格式(月+日),按与数据分桶一致的时区。
|
||||
* 日期所在年份与 [yearContext] 不同时,前缀加年份(如 "2024年7月9日")。
|
||||
*
|
||||
* 日浮层传 [currentYear](查看往年时带年份前缀);周浮层两端传 [selectedYear],
|
||||
* 使跨年周(如 12月28日-次年1月3日)的结束日带 "2026年" 前缀,避免范围倒挂。
|
||||
*/
|
||||
internal fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int): String {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val date = Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate()
|
||||
val yearPrefix = if (date.year != yearContext) "${date.year}年" else ""
|
||||
return "$yearPrefix${date.monthValue}月${date.dayOfMonth}日"
|
||||
}
|
||||
|
||||
/**
|
||||
* 将周数据格式化为浮层/读数条共用的范围文本:"12月28日-12月31日 使用了X token"。
|
||||
*
|
||||
* - 末周只显示到该周落在所选年内的有效日期([dataEndTs],数据最后一天),
|
||||
* 避免出现未绘制的未来/次年空位日期;数据同源,不依赖实时时钟
|
||||
* - 首列跨年(start 在上一年)时,end 与 start 不同年 → end 强制带年份前缀,避免范围倒挂
|
||||
*/
|
||||
internal fun formatWeekRangeText(
|
||||
week: HeatmapWeekData,
|
||||
useUtc8: Boolean,
|
||||
selectedYear: Int,
|
||||
dataEndTs: Long?,
|
||||
): String {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val start = formatDateChinese(week.weekStartTs, useUtc8, selectedYear)
|
||||
val endTs = minOf(week.weekStartTs + 6L * 86_400_000L, dataEndTs ?: (week.weekStartTs + 6L * 86_400_000L))
|
||||
val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate()
|
||||
val endDate = Instant.ofEpochMilli(endTs).atOffset(zone).toLocalDate()
|
||||
val end = if (endDate.year != startDate.year) {
|
||||
"${endDate.year}年${endDate.monthValue}月${endDate.dayOfMonth}日"
|
||||
} else {
|
||||
formatDateChinese(endTs, useUtc8, selectedYear)
|
||||
}
|
||||
return "$start-$end 使用了${formatTokenChinese(week.tokens)}token"
|
||||
}
|
||||
|
||||
// ── 辅助类型与函数 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 水平拖动手势的仲裁模式:
|
||||
* - VIEW:慢速横向拖动 → 滑动查看模式(显示手指所在天的信息,图表锁定不滚动)
|
||||
* - SCROLL:快速横向拖动或纵向拖动 → 放行给 scrollable 正常滚动
|
||||
*/
|
||||
private enum class DragMode { VIEW, SCROLL }
|
||||
|
||||
/** 慢速/快速拖动分界速度(dp/s,密度无关;低于=查看拖动,高于=滚动拖动) */
|
||||
private const val VIEW_SPEED_DP_PER_S = 150f
|
||||
|
||||
/** 月份标签 */
|
||||
private data class MonthLabel(
|
||||
val weekIndex: Int,
|
||||
@ -224,6 +314,13 @@ fun HeatmapCanvas(
|
||||
val selectionColor = MaterialTheme.colorScheme.primary
|
||||
val selectionStrokePx = with(density) { 1.5.dp.toPx() }
|
||||
|
||||
// 滑动查看指示器(三种视图):慢速拖动或长按时"所见即所得"——手指所在格子高亮预览,
|
||||
// 底部读数条显示该格数据(每日/累计=当天,每周=该周);抬起后保留最后预览,切换视图/年份时清除
|
||||
var indicatorDay by remember { mutableStateOf<HeatmapDayData?>(null) }
|
||||
var indicatorWeek by remember { mutableStateOf<HeatmapWeekData?>(null) }
|
||||
var indicatorCol by remember { mutableStateOf(-1) }
|
||||
var indicatorRow by remember { mutableStateOf(-1) }
|
||||
|
||||
// Canvas 在窗口中的位置(浮层锚点定位用)
|
||||
var canvasWindowPos by remember { mutableStateOf(IntOffset.Zero) }
|
||||
|
||||
@ -282,8 +379,12 @@ fun HeatmapCanvas(
|
||||
// 横向滚动状态(图例固定在外层,不随热力图滚动)
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// 打开页面 / 切换视图 / 切换年份时,横向滚动自动定位到最右(最新数据)
|
||||
LaunchedEffect(cols, isWeekly, state.selectedYear) {
|
||||
// 打开页面 / 切换视图 / 切换年份时,横向滚动自动定位到最右(最新数据),并清除滑动查看指示器
|
||||
LaunchedEffect(cols, state.viewMode, state.selectedYear) {
|
||||
indicatorDay = null
|
||||
indicatorWeek = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
scrollState.scrollTo(scrollState.maxValue)
|
||||
}
|
||||
@ -302,8 +403,140 @@ fun HeatmapCanvas(
|
||||
modifier = Modifier
|
||||
.size(canvasWidthDp, totalHeightDp)
|
||||
.onGloballyPositioned { canvasWindowPos = it.positionInWindow().round() }
|
||||
// 手势仲裁(与点击共存):慢速横向拖动/长按=滑动查看(锁定图表、指示器跟手),
|
||||
// 快速横向拖动或纵向拖动=放行给 scrollable 正常滚动;三种视图均启用。
|
||||
// ⚠️ 必须声明在下方 detectTapGestures 之前:仲裁先收到 Main pass 事件,
|
||||
// VIEW 模式先 consume 可阻止 tap 误触发(顺序承重,勿调换)
|
||||
.pointerInput(state, cellStepPx, blockSizePx, gridWeeks, state.viewMode) {
|
||||
// 慢速/快速分界:密度无关(dp/s),保证不同设备手感一致
|
||||
val viewSpeedThresholdPxPerMs = with(density) { VIEW_SPEED_DP_PER_S.dp.toPx() } / 1000f
|
||||
// 指示器更新:手指所在列/行的格子(所见即所得)。
|
||||
// change.position 已是滚动内容内的本地坐标(与 detectTapGestures 命中一致),
|
||||
// 不能再加 scrollState.value,否则滚动后列索引整体偏右
|
||||
fun updateIndicator(pos: Offset) {
|
||||
val col = (pos.x / cellStepPx).toInt().coerceIn(0, cols - 1)
|
||||
val row = (pos.y / cellStepPx).toInt().coerceIn(0, rows - 1)
|
||||
if (isWeekly) {
|
||||
// 每周视图:整列=同一周(柱状图),读数条显示该周范围与总量
|
||||
val week = state.weeklyData.getOrNull(col)
|
||||
if (week != null) {
|
||||
indicatorDay = null
|
||||
indicatorWeek = week
|
||||
indicatorCol = col
|
||||
indicatorRow = row
|
||||
} else {
|
||||
indicatorDay = null
|
||||
indicatorWeek = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
}
|
||||
} else {
|
||||
// 每日/累计视图:格子=一天(累计视图 tokens 为累计值)
|
||||
val cell = gridWeeks.getOrNull(col)?.getOrNull(row)
|
||||
if (cell != null) {
|
||||
indicatorWeek = null
|
||||
indicatorDay = cell
|
||||
indicatorCol = col
|
||||
indicatorRow = row
|
||||
} else {
|
||||
indicatorWeek = null
|
||||
indicatorDay = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
// 模式判定(本次手势内锁定):
|
||||
// - 按住不动/慢速横向拖动 → VIEW:指示器跟手、消费事件锁定图表滚动
|
||||
// - 快速横向拖动或纵向主导 → SCROLL:不消费,放行给 scrollable 正常滚动
|
||||
var mode: DragMode? = null
|
||||
var lastPos = down.position
|
||||
var lastTime = SystemClock.uptimeMillis()
|
||||
var totalDx = 0f
|
||||
var totalDy = 0f
|
||||
val slop = viewConfiguration.touchSlop
|
||||
val downTime = lastTime
|
||||
// 长按阈值:按住不动(含微移未超 slop)超过系统长按时长 → 直接进入查看模式。
|
||||
// 静止时系统不一定派发 move 事件,故用 withTimeoutOrNull 做超时心跳,不依赖事件频率。
|
||||
// ⚠️ 此符号解析为 AwaitPointerEventScope 的框架成员(非 kotlinx 协程版),
|
||||
// 勿补 import kotlinx.coroutines.withTimeoutOrNull(会被接口成员遮蔽且语义不同)
|
||||
val longPressMs = viewConfiguration.longPressTimeoutMillis
|
||||
|
||||
// 阶段一:模式判定。位移超 slop 按帧速度判 VIEW/SCROLL;提前抬起 = 点击放行给 tap;
|
||||
// 长按超时(手指未动)→ VIEW
|
||||
while (mode == null) {
|
||||
val remaining = longPressMs - (SystemClock.uptimeMillis() - downTime)
|
||||
val event = if (remaining > 0) {
|
||||
withTimeoutOrNull(remaining) { awaitPointerEvent() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (event == null) {
|
||||
// 长按成立:按住不动(或微移 < slop)→ 查看模式
|
||||
mode = DragMode.VIEW
|
||||
break
|
||||
}
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) break // 快速抬起 = 点击,交给 detectTapGestures
|
||||
val cur = change.position
|
||||
val now = SystemClock.uptimeMillis()
|
||||
// 帧瞬时速度:停顿后快甩的帧速度大 → 判为滚动;慢速拖动各帧速度都小 → 判为查看
|
||||
val dxFrame = cur.x - lastPos.x
|
||||
val dyFrame = cur.y - lastPos.y
|
||||
val dtFrame = (now - lastTime).coerceAtLeast(1L)
|
||||
val speedX = abs(dxFrame) / dtFrame
|
||||
lastPos = cur
|
||||
lastTime = now
|
||||
totalDx += dxFrame
|
||||
totalDy += dyFrame
|
||||
// 首次超过触摸阈值时判定模式(本次手势内锁定):
|
||||
// 横向主导且慢速 → VIEW(查看);否则 → SCROLL(滚动)
|
||||
if (abs(totalDx) > slop || abs(totalDy) > slop) {
|
||||
mode = if (abs(totalDx) > abs(totalDy) && speedX < viewSpeedThresholdPxPerMs) {
|
||||
DragMode.VIEW
|
||||
} else {
|
||||
DragMode.SCROLL
|
||||
}
|
||||
if (mode == DragMode.VIEW) {
|
||||
// 判决事件立即消费:否则同帧(Main pass leaf→root)会漏到父 scrollable,
|
||||
// 其越过自身 slop 先滚一帧(约 0~4px 抖动)后才被 phase2 消费取消
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode == DragMode.VIEW) {
|
||||
// 进入查看模式立即用最后已知位置更新指示器(长按超时瞬间就要显示,不等下一个事件)
|
||||
updateIndicator(lastPos)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
// 先消费再判抬起:up 若不消费会漏到 detectTapGestures 误触发 onTap 浮层
|
||||
// (waitForUpOrCancellation 对 consumed up 返回 null → tap 取消),
|
||||
// 与慢拖路径行为保持一致
|
||||
updateIndicator(change.position)
|
||||
change.consume() // 消费事件锁定图表滚动
|
||||
if (!change.pressed) break // 抬起:保留最后预览的格子
|
||||
}
|
||||
} else if (mode == DragMode.SCROLL) {
|
||||
// 滚动模式:清除上次预览的指示器,避免滚动后残留过期信息;
|
||||
// 不消费事件,scrollable 正常滚动(含惯性)
|
||||
indicatorDay = null
|
||||
indicatorWeek = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(state, cellStepPx, blockSizePx, gridWeeks) {
|
||||
detectTapGestures(onTap = { offset ->
|
||||
// 点击时清除滑动查看残留的预览(抬起后保留的指示器),避免与浮层同时显示
|
||||
indicatorDay = null
|
||||
indicatorWeek = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
// 点击落在格子间隙内时不触发(Minor:命中精度)
|
||||
if (offset.x % cellStepPx >= blockSizePx || offset.y % cellStepPx >= blockSizePx) {
|
||||
return@detectTapGestures
|
||||
@ -388,6 +621,51 @@ fun HeatmapCanvas(
|
||||
nativeCanvas.drawText(label.label, x, labelY, monthLabelPaint)
|
||||
}
|
||||
}
|
||||
|
||||
// 预览格子高亮(三种视图,所见即所得):手指所在格子加主题色边框。
|
||||
// 防御性校验:指示器指向的格子数据仍存在(防未来"同一年份数据刷新"后残留过期高亮)
|
||||
val indicatorValid = when {
|
||||
indicatorWeek != null -> state.weeklyData.getOrNull(indicatorCol) != null
|
||||
indicatorDay != null -> gridWeeks.getOrNull(indicatorCol)?.getOrNull(indicatorRow) != null
|
||||
else -> false
|
||||
}
|
||||
if (indicatorValid &&
|
||||
indicatorCol in 0 until cols && indicatorRow in 0 until rows
|
||||
) {
|
||||
drawRoundRect(
|
||||
color = selectionColor,
|
||||
topLeft = Offset(indicatorCol * cellStepPx, indicatorRow * cellStepPx),
|
||||
size = Size(blockSizePx, blockSizePx),
|
||||
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
|
||||
style = Stroke(width = selectionStrokePx * 1.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 滑动查看读数条(三种视图):显示手指所在格子的数据(每日/累计=当天,每周=该周范围),
|
||||
// 抬起后保留;固定高度占位,避免出现/消失导致图例上下跳动
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(20.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val d = indicatorDay
|
||||
val w = indicatorWeek
|
||||
val text = when {
|
||||
d != null -> "${formatDateChinese(d.dayTs, state.useUtc8, state.currentYear)} 使用了${formatTokenChinese(d.tokens)}token"
|
||||
w != null -> formatWeekRangeText(w, state.useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs)
|
||||
else -> null
|
||||
}
|
||||
if (text != null) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 12.sp,
|
||||
color = mutedColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -54,8 +54,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@ -234,25 +232,9 @@ fun HeatmapScreen(
|
||||
val anchor = popupAnchor
|
||||
if (day != null || week != null) {
|
||||
val useUtc8 = state.useUtc8
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val popupText = when {
|
||||
day != null -> "${formatDateChinese(day.dayTs, useUtc8, state.currentYear)} 使用了${formatTokenChinese(day.tokens)}token"
|
||||
week != null -> {
|
||||
val start = formatDateChinese(week.weekStartTs, useUtc8, state.selectedYear)
|
||||
// 末周/跨年周只显示到该周落在所选年内的有效日期(数据最后一天),
|
||||
// 避免出现未绘制的未来/次年空位日期;数据同源,不依赖实时时钟
|
||||
val dataEndTs = state.dailyData.lastOrNull()?.dayTs ?: (week.weekStartTs + 6L * 86_400_000L)
|
||||
val endTs = minOf(week.weekStartTs + 6L * 86_400_000L, dataEndTs)
|
||||
// 首列跨年(start 在上一年)时,end 与 start 不同年 → end 也强制带年份前缀,避免范围歧义
|
||||
val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate()
|
||||
val endDate = Instant.ofEpochMilli(endTs).atOffset(zone).toLocalDate()
|
||||
val end = if (endDate.year != startDate.year) {
|
||||
"${endDate.year}年${endDate.monthValue}月${endDate.dayOfMonth}日"
|
||||
} else {
|
||||
formatDateChinese(endTs, useUtc8, state.selectedYear)
|
||||
}
|
||||
"$start-$end 使用了${formatTokenChinese(week.tokens)}token"
|
||||
}
|
||||
week != null -> formatWeekRangeText(week, useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs)
|
||||
else -> ""
|
||||
}
|
||||
// 浮层相对锚点的偏移:水平居中、优先显示在格子上方 8dp 处;
|
||||
@ -285,7 +267,9 @@ fun HeatmapScreen(
|
||||
selectedWeek = null
|
||||
popupAnchor = null
|
||||
},
|
||||
properties = PopupProperties(focusable = true)
|
||||
// 不拦截图表交互:浮层不获取焦点,点击/滑动直接作用于图表,
|
||||
// 点击其他格子即切换浮层,再次点击同一格子关闭
|
||||
properties = PopupProperties(focusable = false)
|
||||
) {
|
||||
Surface(
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
@ -304,51 +288,3 @@ fun HeatmapScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 格式化辅助函数 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将 Token 数按中文数量级格式化:
|
||||
* - 亿级(≥1亿):1.9亿
|
||||
* - 万级(≥1万):11.2万 / 1124.5万
|
||||
* - 万以下:原始数字
|
||||
*
|
||||
* 注意:万级先舍入到一位小数再判断是否升亿(如 99999999 → "1.0亿",而非 "10000.0万")。
|
||||
*/
|
||||
private fun formatTokenChinese(tokens: Long): String {
|
||||
return when {
|
||||
tokens >= 1_0000_0000 -> {
|
||||
val v = tokens / 1_0000_0000.0
|
||||
if (v >= 100) "${v.roundToInt()}亿"
|
||||
else "${"%.1f".format(v)}亿"
|
||||
}
|
||||
tokens >= 1_0000 -> {
|
||||
val v = tokens / 1_0000.0
|
||||
// 先舍入到一位小数,避免 "99999999 → 10000.0万"
|
||||
val rounded = (v * 10).roundToInt() / 10.0
|
||||
if (rounded >= 10000) {
|
||||
// 万级溢出升亿:9999.99万 → 1.0亿
|
||||
val v2 = rounded / 10000.0
|
||||
if (v2 >= 100) "${v2.roundToInt()}亿"
|
||||
else "${"%.1f".format(v2)}亿"
|
||||
} else {
|
||||
"${"%.1f".format(v)}万" // 始终保留一位小数
|
||||
}
|
||||
}
|
||||
else -> "$tokens"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间戳格式化为 "7月9日" 格式(月+日),按与数据分桶一致的时区。
|
||||
* 日期所在年份与 [yearContext] 不同时,前缀加年份(如 "2024年7月9日")。
|
||||
*
|
||||
* 日浮层传 [currentYear](查看往年时带年份前缀);周浮层两端传 [selectedYear],
|
||||
* 使跨年周(如 12月28日-次年1月3日)的结束日带 "2026年" 前缀,避免范围倒挂。
|
||||
*/
|
||||
private fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int): String {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val date = Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate()
|
||||
val yearPrefix = if (date.year != yearContext) "${date.year}年" else ""
|
||||
return "$yearPrefix${date.monthValue}月${date.dayOfMonth}日"
|
||||
}
|
||||
@ -68,6 +68,11 @@
|
||||
|
||||
## 七、格子交互
|
||||
|
||||
- **滑动查看(三种视图,OCGO 风格)**:在图表上手指拖动或长按,按速度/时长仲裁两种模式:
|
||||
- **长按(按住不动 ≥ 系统长按时长)→ 查看模式**:手指所在格子直接高亮预览(所见即所得),底部读数条显示该格数据;按住期间拖动可切换预览格子,抬起后保留
|
||||
- **慢速横向拖动(< 约150dp/s)→ 查看模式**:图表锁定不滚动(保证稳定),**所见即所得**——手指所在格子加主题色边框高亮预览,底部读数条实时显示该格数据(抬起后保留,切换视图/年份清除)
|
||||
- **快速横向拖动(≥ 约150dp/s)→ 滚动模式**:放行给图表正常左右滚动浏览(含惯性)
|
||||
- 读数条语义与视图一致:**每日/累计**=该天"X月X日 使用了X token"(累计视图 tokens 为累计值);**每周**=该周范围"X月X日-X月X日 使用了X token"(与周浮层同口径,末周夹取到数据最后一天、跨年带年份前缀)
|
||||
- 点击格子显示浮层,内容为日期和当天使用的 Token 数
|
||||
- 浮层格式示例:
|
||||
```
|
||||
@ -77,7 +82,8 @@
|
||||
7月12日 使用了16783token
|
||||
```
|
||||
(Token 数按数量级自然中文读法显示)
|
||||
- 再次点击或点击外部关闭浮层
|
||||
- **浮层保留卡片外观,但不拦截图表交互**(Popup focusable=false):点击/滑动直接作用于图表——点其他格子即切换浮层,再次点击同一格子关闭,手指滑动查看图表不受浮层影响
|
||||
- 图表支持手指横向滑动查看(配合年份选择器浏览任意年份),页面纵向滚动
|
||||
- 不需要"查看详情"按钮
|
||||
- 热力格支持键盘聚焦,提供日期与 Token 数的无障碍描述
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user